Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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`
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
12 changes: 11 additions & 1 deletion src/bin/t3_wb_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 15 additions & 4 deletions src/bin/t3ctl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ fn run(mut args: Vec<String>) -> Result<()> {
config::save_setting("power", "sleep")
}
"wake" => {
open()?.wake()?;
wake_managed(open()?)?;
config::save_setting("power", "awake")
}
"auto" => config::save_setting("power", "auto"),
Expand All @@ -148,7 +148,7 @@ fn run(mut args: Vec<String>) -> 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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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()
);
Expand All @@ -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(),
Expand Down Expand Up @@ -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()?;
Expand All @@ -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<f64> {
Expand Down
48 changes: 48 additions & 0 deletions src/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
}
Loading