From b6c1bad6dab7ca2ed261822adece97148e3302e3 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Mon, 28 Sep 2026 14:27:33 -0500 Subject: [PATCH] Disable firmware auto-sleep instead of repeatedly waking camera --- PROTOCOL.md | 22 +++++++++++++++++++ README.md | 11 +++++++++- src/bin/t3_wb_guard.rs | 12 ++++++++++- src/bin/t3ctl.rs | 19 +++++++++++++---- src/device.rs | 48 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 6 deletions(-) diff --git a/PROTOCOL.md b/PROTOCOL.md index 4d83ee1..97bb3bd 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -131,6 +131,7 @@ Payload is always `[tag, 0x02_or_0x01, ...]` zero-padded to 60. | Tag | Control | Value | Result | |---|---|---|---| +| `0x0b` | Firmware auto-sleep timer | `[0x02, seconds_lo, seconds_hi]`; zero disables; readback at status `0x0a/0x0b` | ✅ Tiny 3 Lite 120→0 verified | | `0x16` | AI tracking | `[0x02, category, submode]` (see below) | ✅ verified; status byte `0x18`=category, `0x1c`=submode | | `0x01` | HDR / WDR | `[0x01, on]` | ✅ toggles (status byte `0x06`) | | `0x03` | face-priority AE | `[0x01, mode]` — 0 global / 1 face (needs auto-exposure on) | ⚠️ command accepted; not visually verified | @@ -183,6 +184,7 @@ Live blob observed: `2e0100020000000100 01 78 0000 01 01 …`. Decoded offsets | Offset | Field | Status | |---|---|---| +| `0x0a/0x0b` | auto-sleep seconds, u16 LE; 0 disables | ✅ 120→0 and 600→0 verified; camera remained awake | | `0x02` | sleep (0=awake, 1=sleep) | ✅ reacts (`00` awake / `01` asleep) | | `0x06` | HDR (bool) | ✅ reacts to the HDR TLV | | `0x18` | AI mode enable | ✅ `00` idle → `02` when human-tracking on | @@ -267,6 +269,26 @@ wake when needed. Explicit user sleep overrides this protection. A control write completing is not sufficient evidence of a completed gimbal/power transition; sleep/wake now poll readback for up to three seconds. +**2026-09-28 correction:** repeated natural sleep/wake was observed every +124–126 seconds while the guard held a control descriptor and ALSA capture was +RUNNING. Selector-6 status bytes 10..12 contained `78 00` (120 seconds). +Sending raw TLV `0b 02 00 00` changed those bytes to `00 00`; the sleep bit +remained zero. This is the firmware auto-sleep timer, not USB autosuspend. +The guard must disable this timer **before** it expires, not merely recover +from sleep after the timer has already parked the camera. Timer command layout +is independently documented by [meet4k](https://github.com/samliddicott/meet4k) +and the [OBSBOT SDK call audit](https://github.com/vampyren/obsbot4linux/blob/main/docs/dev/SDK_REDROP_2026-05.md) +(`cameraSetSuspendTimeU`, TLV header `0x020b`). Tiny 3 Lite live readback was +confirmed here; other models remain unverified for this field. + +Validation: a seven-minute observation (1,638 status samples) after disabling +the timer saw zero sleep samples and zero nonzero-timer samples. After installing +the updated guard, injecting `0b 02 58 02` verified the full 600-second readback; +the guard cleared it to zero within the test's five-second deadline without a +sleep transition. Journal history showed no reactive wake events after the +120-second timer was disabled. + + ### 3. Chromium resets WB on every stream open Chromium-based apps (Chrome, Slack, Electron) write `white_balance_automatic=1` diff --git a/README.md b/README.md index 0fab4cb..feda351 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,8 @@ CLI and guard operations share a lock so they cannot overwrite one another mid-command. Commands check camera readback and report failures; a saved setting remains pending if firmware rejects it, and the guard retries. -- `t3ctl wake`: stay awake, including without a video preview, until Sleep/Auto. +- `t3ctl wake`: disable firmware auto-sleep and stay awake, including without a + video preview, until Sleep/Auto. - `t3ctl sleep`: explicit sleep overrides active calls and firmware wake attempts. - `t3ctl auto`: wake when an app uses video **or the camera microphone**; release the keep-awake descriptor when idle. This is the default for existing configs. @@ -177,6 +178,14 @@ setting remains pending if firmware rejects it, and the guard retries. The guard matches ALSA capture to the same physical USB device as the camera, so changing sound-card numbers or using a different microphone is safe. It reads capture activity from procfs; it does not record or start video capture. +The firmware auto-sleep timer is separate from USB autosuspend: microphone +capture and an open video control descriptor do not prevent its countdown. +Whenever Awake or an active Auto call requires the camera awake, the guard +sets this timer to zero and verifies readback. It rechecks the timer during use +so an app/firmware reset cannot silently restore timed sleep. The timer is not +restored when leaving active use; explicit Sleep and USB idle suspend still work. +`t3ctl status --json` reports `auto_sleep_seconds` (zero means disabled). + Defaults are tracking off and manual WB at 4000K. HDR remains unmanaged until explicitly selected. Power transitions wait for vendor readback. diff --git a/src/bin/t3_wb_guard.rs b/src/bin/t3_wb_guard.rs index d12709b..ca5ed71 100644 --- a/src/bin/t3_wb_guard.rs +++ b/src/bin/t3_wb_guard.rs @@ -70,6 +70,16 @@ fn main() { return dev.set_wb_temp(config.wb_temp); } let mut status = dev.status()?; + // Prevent timed sleep before it happens. Reactively waking the + // camera every time this timer fires causes visible power cycling. + // Recheck on every active tick to cover firmware/app/replug resets. + if keep_awake && status.auto_sleep_seconds != 0 { + dev.disable_auto_sleep()?; + eprintln!( + "t3-wb-guard: disabled firmware auto-sleep (was {}s)", + status.auto_sleep_seconds + ); + } if config.power == "sleep" && !status.asleep { dev.sleep()?; eprintln!("t3-wb-guard: restored requested sleep"); @@ -98,7 +108,7 @@ fn main() { // Verify vendor SETs as well as UVC controls. A successful ioctl // only means transport success, not that firmware applied it. let actual = dev.status()?; - if (keep_awake && actual.asleep) + if (keep_awake && (actual.asleep || actual.auto_sleep_seconds != 0)) || (config.power == "sleep" && !actual.asleep) || actual.tracking != config.tracking || config.hdr.is_some_and(|h| h != actual.hdr) diff --git a/src/bin/t3ctl.rs b/src/bin/t3ctl.rs index 2639a81..a85695e 100644 --- a/src/bin/t3ctl.rs +++ b/src/bin/t3ctl.rs @@ -137,7 +137,7 @@ fn run(mut args: Vec) -> Result<()> { config::save_setting("power", "sleep") } "wake" => { - open()?.wake()?; + wake_managed(open()?)?; config::save_setting("power", "awake") } "auto" => config::save_setting("power", "auto"), @@ -148,7 +148,7 @@ fn run(mut args: Vec) -> Result<()> { // state so a keybinding can surface it in a notification. let st = dev.status()?; if st.asleep { - dev.wake()?; + wake_managed(dev)?; config::save_setting("power", "awake")?; println!("awake"); } else { @@ -266,7 +266,7 @@ fn cmd_status(dev: &Device, json: bool) -> Result<()> { println!( "{{\"asleep\":{},\"tracking\":\"{}\",\"tracking_sport\":{},\"hdr\":{},\ \"auto_wb\":{},\"wb_temp\":{},\"pan_deg\":{:.1},\"tilt_deg\":{:.1},\ - \"zoom\":{},\"auto_exposure\":{},\"power_policy\":\"{}\",\"desired_tracking\":\"{}\"}}", + \"zoom\":{},\"auto_exposure\":{},\"auto_sleep_seconds\":{},\"power_policy\":\"{}\",\"desired_tracking\":\"{}\"}}", s.asleep, json_str(s.tracking.label()), s.tracking_sport, @@ -277,6 +277,7 @@ fn cmd_status(dev: &Device, json: bool) -> Result<()> { s.tilt_deg, s.zoom, s.auto_exposure, + s.auto_sleep_seconds, desired.power, desired.tracking.label() ); @@ -285,6 +286,10 @@ fn cmd_status(dev: &Device, json: bool) -> Result<()> { "power : {}", if s.asleep { "asleep" } else { "awake" } ); + println!( + "sleep timer : {} seconds (0 = disabled)", + s.auto_sleep_seconds + ); println!( "tracking : {}{}", s.tracking.label(), @@ -458,7 +463,7 @@ fn cmd_exposure(dev: &Device, rest: &[String]) -> Result<()> { } fn cmd_reset(dev: &Device) -> Result<()> { - dev.wake()?; + wake_managed(dev)?; dev.set_tracking(TrackMode::Off)?; dev.recenter()?; dev.set_wb_auto()?; @@ -469,6 +474,12 @@ fn cmd_reset(dev: &Device) -> Result<()> { Ok(()) } +/// A persistent Wake choice must first disable the independent firmware timer. +fn wake_managed(dev: &Device) -> Result<()> { + dev.disable_auto_sleep()?; + dev.wake() +} + // --- small arg helpers --- fn need_f64(rest: &[String], what: &str) -> Result { diff --git a/src/device.rs b/src/device.rs index e910f33..62bf85e 100644 --- a/src/device.rs +++ b/src/device.rs @@ -36,10 +36,12 @@ const TLV_HDR: u8 = 0x01; const TLV_FACE_AE: u8 = 0x03; const TLV_FOV: u8 = 0x04; const TLV_AI_TRACK: u8 = 0x16; +const TLV_AUTO_SLEEP: u8 = 0x0b; // Status-block byte offsets (decoded from Tiny4Linux, confirmed reacting live). const ST_SLEEP: usize = 0x02; const ST_HDR: usize = 0x06; +const ST_AUTO_SLEEP: usize = 0x0a; const ST_AI_CATEGORY: usize = 0x18; const ST_AI_SUBMODE: usize = 0x1c; const ST_TRACK_SPEED: usize = 0x21; @@ -131,6 +133,8 @@ impl TrackMode { #[derive(Debug, Clone)] pub struct Status { pub asleep: bool, + /// Firmware idle timer in seconds; zero disables vendor auto-sleep. + pub auto_sleep_seconds: u16, pub hdr: bool, pub tracking: TrackMode, pub tracking_sport: bool, @@ -234,6 +238,25 @@ impl Device { self.set_power(false) } + /// Disable the firmware idle timer, independently of USB autosuspend. + /// An open control fd and active microphone do NOT inhibit this timer. + /// Raw selector-6 TLV 0x0b carries a little-endian u16 seconds value. + /// Readback at status bytes 10..12 is verified on Tiny 3 Lite. + pub fn disable_auto_sleep(&self) -> Result<()> { + if decode_auto_sleep(&self.fd.xu_get(XU_UNIT, SEL_STATUS)?) == 0 { + return Ok(()); + } + self.fd + .xu_set(XU_UNIT, SEL_STATUS, &[TLV_AUTO_SLEEP, 2, 0, 0])?; + for _ in 0..10 { + std::thread::sleep(Duration::from_millis(50)); + if decode_auto_sleep(&self.fd.xu_get(XU_UNIT, SEL_STATUS)?) == 0 { + return Ok(()); + } + } + Err(Error::NoReply("disabled firmware auto-sleep timer")) + } + fn set_power(&self, asleep: bool) -> Result<()> { self.send_set(RCV_CAMERA, CMD_DEV_STATUS, &[asleep as u8, 0, 0, 0])?; // Gimbal/firmware transition takes longer than a control transfer. @@ -430,6 +453,7 @@ impl Device { let ae = self.fd.get_ctrl(controls::CID_EXPOSURE_AUTO)?; Ok(Status { asleep: s[ST_SLEEP] != 0, + auto_sleep_seconds: decode_auto_sleep(&s), hdr: s[ST_HDR] != 0, tracking: TrackMode::from_status(s[ST_AI_CATEGORY], s[ST_AI_SUBMODE]), tracking_sport: s[ST_TRACK_SPEED] == 2, @@ -442,3 +466,27 @@ impl Device { }) } } + +fn decode_auto_sleep(status: &[u8; 60]) -> u16 { + u16::from_le_bytes([status[ST_AUTO_SLEEP], status[ST_AUTO_SLEEP + 1]]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn firmware_sleep_timer_is_little_endian_and_distinct_from_sleep_state() { + // Captured prefix from the affected Tiny 3 Lite: awake but configured + // to park itself after 120 seconds, despite an active microphone. + let mut status = [0; 60]; + status[..14].copy_from_slice(&[0x2e, 1, 0, 2, 0, 0, 0, 1, 0, 1, 120, 0, 0, 1]); + assert_eq!(decode_auto_sleep(&status), 120); + status[ST_SLEEP] = 1; + assert_eq!(decode_auto_sleep(&status), 120); + status[ST_AUTO_SLEEP..ST_AUTO_SLEEP + 2].copy_from_slice(&600u16.to_le_bytes()); + assert_eq!(decode_auto_sleep(&status), 600); + status[ST_AUTO_SLEEP..ST_AUTO_SLEEP + 2].fill(0); + assert_eq!(decode_auto_sleep(&status), 0); + } +}