From 5aa70fbcf74aa7073151ffc35005cd8345a31d5f Mon Sep 17 00:00:00 2001 From: hutzelmann <18736645+hutzelmann@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:42:50 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20#72:=20Detect=20the=20OBSBOT=20?= =?UTF-8?q?Tiny=204K?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device hint was hardcoded to "OBSBOT Tiny 2" in the GUI and the CLI, and open_camera matches that hint against the V4L2 card name. A Tiny 4K reports "OBSBOT Tiny 4K: OBSBOT Tiny 4K", so NoCameraFound was returned and both applications claimed that no camera is connected, although the camera works everywhere else. Camera::detect() now tries "OBSBOT Tiny 2" first and falls back to "OBSBOT Tiny 4K". A connected Tiny 2 is still preferred, so nothing changes for Tiny 2 users, and Camera::new(hint) is untouched. Verified on an OBSBOT Tiny 4K (USB 3564:fef4): the camera is detected and its status is read. Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5 --- README.md | 8 ++++++++ src/cli/main.rs | 2 +- src/gui/main.rs | 4 ++-- src/libs/camera/camera.rs | 15 +++++++++++++++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 889391f..0a83428 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,14 @@ which itself is substantially based on [samliddicott's meet4k package](https://g - UI-Upgrade (WIP) - Tests & Codecov check +### Supported Cameras + +Tiny4Linux is developed and tested with the **OBSBOT Tiny 2**. +If no Tiny 2 is connected, an **OBSBOT Tiny 4K** is used instead ([#72](https://github.com/OpenFoxes/Tiny4Linux/issues/72)). + +The Tiny 4K ignores settings while it is in standby (about two minutes without a video stream). +Start a video stream first, then the settings are applied immediately. + ### Info on PTZ-/Gimbal-Controls The main focus of the [original project](https://github.com/cgevans/tiny2) was the implementation of OBSBOT-specific functionalities on Linux. diff --git a/src/cli/main.rs b/src/cli/main.rs index ebd50d4..f99fb3a 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -106,7 +106,7 @@ fn main() { let args = Args::parse(); - let mut camera = Camera::new("OBSBOT Tiny 2").ok(); + let mut camera = Camera::detect().ok(); if camera.is_none() { println!("{}", t!("shared.errors.no_camera")); diff --git a/src/gui/main.rs b/src/gui/main.rs index 18a7594..1b1145d 100644 --- a/src/gui/main.rs +++ b/src/gui/main.rs @@ -54,7 +54,7 @@ struct MainPanel { impl MainPanel { fn init_state(window_mode: WindowMode) -> (Self, Task) { - let camera = Camera::new("OBSBOT Tiny 2").ok(); + let camera = Camera::detect().ok(); let status = camera .as_ref() @@ -80,7 +80,7 @@ impl MainPanel { fn update(&mut self, message: Message) -> Task { if self.camera.is_none() { - self.camera = Camera::new("OBSBOT Tiny 2").ok(); + self.camera = Camera::detect().ok(); if self.camera.is_none() { return Task::none(); diff --git a/src/libs/camera/camera.rs b/src/libs/camera/camera.rs index 3e774b5..2b12ee3 100644 --- a/src/libs/camera/camera.rs +++ b/src/libs/camera/camera.rs @@ -10,6 +10,13 @@ use crate::{ }; use errno::Errno; +/// Device hints tried in this order by [`Camera::detect`]. +/// +/// A hint has to be part of the V4L2 card name of the camera. +/// The Tiny 2 is the primarily supported model and therefore preferred, +/// the Tiny 4K is only found if no Tiny 2 is connected (#72). +const DEFAULT_CAMERA_HINTS: [&str; 2] = ["OBSBOT Tiny 2", "OBSBOT Tiny 4K"]; + pub struct Camera { transport: CameraTransport, debugging: bool, @@ -23,6 +30,14 @@ impl Camera { }) } + /// Opens the first camera matching one of the default hints, see [`DEFAULT_CAMERA_HINTS`]. + pub fn detect() -> Result { + DEFAULT_CAMERA_HINTS + .iter() + .find_map(|hint| Self::new(hint).ok()) + .ok_or(T4lError::NoCameraFound) + } + pub fn info(&self) -> Result<(), Errno> { self.transport.info() } From 2a84e81942191b350294fecf9c27d56dafe4c8d3 Mon Sep 17 00:00:00 2001 From: hutzelmann <18736645+hutzelmann@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:43:08 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20#72:=20Sleep=20and=20wake=20for?= =?UTF-8?q?=20the=20OBSBOT=20Tiny=204K?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tiny 4K accepts the Tiny 2 sleep frame on USB but ignores it. It is architecturally the original OBSBOT Tiny and speaks an older frame format, so sleep and wake now dispatch on the detected model. The Tiny 2 path is unchanged. Its sleep state is read from status byte 0x09, the device run status, rather than byte 0x02, which is a reserved and always zero field on the 4K. That is why info reported the Tiny 4K as awake while it was asleep. The frames and the status offset were captured from the vendor app driving a Tiny 4K and verified against the camera. The README gains the feature matrix for this model. Co-Authored-By: Claude Opus 5 --- README.md | 12 +++ src/libs/camera/camera.rs | 25 +++-- src/libs/camera/command_legacy.rs | 128 +++++++++++++++++++++++ src/libs/camera/commands/mod.rs | 2 + src/libs/camera/commands/sleep_legacy.rs | 73 +++++++++++++ src/libs/camera/enums.rs | 35 +++++++ src/libs/camera/mod.rs | 2 + src/libs/camera/status.rs | 27 ++++- src/libs/camera/transport.rs | 9 +- 9 files changed, 303 insertions(+), 10 deletions(-) create mode 100644 src/libs/camera/command_legacy.rs create mode 100644 src/libs/camera/commands/sleep_legacy.rs diff --git a/README.md b/README.md index 0a83428..b7f7e1b 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,18 @@ which itself is substantially based on [samliddicott's meet4k package](https://g Tiny4Linux is developed and tested with the **OBSBOT Tiny 2**. If no Tiny 2 is connected, an **OBSBOT Tiny 4K** is used instead ([#72](https://github.com/OpenFoxes/Tiny4Linux/issues/72)). +The Tiny 4K only understands a part of the Tiny 2 commands: + +| Feature | OBSBOT Tiny 4K | +| ------------------------------- | ------------------------------------------------------------ | +| HDR on/off | ✅ works | +| Exposure mode `face` / `global` | ✅ works | +| Sleep / wake | ✅ works, using the older protocol of the Tiny 4K | +| Status (`info`) | ⚠️ HDR and sleep state are reported, the rest shows defaults | +| Tracking modes | ❌ no effect, the Tiny 4K uses different commands | +| Tracking speed | ❌ the Tiny 4K has no tracking speed setting | +| Preset positions | ❌ no effect, the Tiny 4K has no single recall command | +| Exposure mode `manual` | ❌ no effect | The Tiny 4K ignores settings while it is in standby (about two minutes without a video stream). Start a video stream first, then the settings are applied immediately. diff --git a/src/libs/camera/camera.rs b/src/libs/camera/camera.rs index 2b12ee3..6e8bf94 100644 --- a/src/libs/camera/camera.rs +++ b/src/libs/camera/camera.rs @@ -1,12 +1,12 @@ // SPDX-License-Identifier: EUPL-1.2 -use crate::libs::camera::enums::{AIMode, ExposureMode, SleepMode, TrackingSpeed}; +use crate::libs::camera::enums::{AIMode, CameraModel, ExposureMode, SleepMode, TrackingSpeed}; use crate::libs::camera::status::CameraStatus; use crate::libs::camera::transport::CameraTransport; use crate::libs::errors::T4lError; use crate::{ AIModeCommand, ExposureModeCommand, ExposureModeTypeCommand, GotoPresetPositionCommand, - HdrModeCommand, SleepCommand, TrackingSpeedCommand, + HdrModeCommand, LegacySleepCommand, SleepCommand, TrackingSpeedCommand, }; use errno::Errno; @@ -19,6 +19,7 @@ const DEFAULT_CAMERA_HINTS: [&str; 2] = ["OBSBOT Tiny 2", "OBSBOT Tiny 4K"]; pub struct Camera { transport: CameraTransport, + model: CameraModel, debugging: bool, } @@ -26,6 +27,7 @@ impl Camera { pub fn new(hint: &str) -> Result { Ok(Self { transport: CameraTransport::new(hint)?, + model: CameraModel::from_hint(hint), debugging: false, }) } @@ -47,7 +49,12 @@ impl Camera { } pub fn get_status(&self) -> Result { - self.transport.get_status(self.debugging) + self.transport.get_status(self.debugging, self.model) + } + + /// The model this camera was detected as, see [`CameraModel`]. + pub fn model(&self) -> CameraModel { + self.model } pub fn dump(&self) -> Result<(), Errno> { @@ -77,10 +84,16 @@ pub trait Tiny2Camera { } impl Tiny2Camera for Camera { + /// Sends the sleep command matching the detected model. + /// + /// The Tiny 4K accepts the Tiny 2 frame on USB but ignores it, so it gets the + /// legacy frame instead (#72). Both models take the command on unit 2, + /// selector 2. fn set_sleep_mode(&self, mode: SleepMode) -> Result<(), T4lError> { - let cmd = SleepCommand::build(mode)?; - - self.send_cmd(0x2, 0x2, &cmd) + match self.model { + CameraModel::Tiny2 => self.send_cmd(0x2, 0x2, &SleepCommand::build(mode)?), + CameraModel::Tiny4K => self.send_cmd(0x2, 0x2, &LegacySleepCommand::build(mode)?), + } } fn get_sleep_mode(&self) -> Result { diff --git a/src/libs/camera/command_legacy.rs b/src/libs/camera/command_legacy.rs new file mode 100644 index 0000000..91f7b7c --- /dev/null +++ b/src/libs/camera/command_legacy.rs @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: EUPL-1.2 + +/// Builder for the *legacy* control frame used by the OBSBOT Tiny 4K (#72). +/// +/// The Tiny 4K is architecturally the original OBSBOT Tiny, not a cut-down Tiny 2, +/// and it does not understand the Tiny 2 frames built by [`crate::command02`]. +/// It accepts this older frame instead: +/// +/// ```text +/// aa 00 00 +/// ``` +/// +/// - `len` is the frame length including the 12 byte header +/// - `type` is `0x10` for a SET (the camera answers with `0x11` / `0x13`) +/// - `crc` is CRC-16/USB over `frame[0..len]` with the checksum bytes themselves +/// zeroed, stored big-endian +/// - `route` addresses a subsystem: `0xe1` camera, `0xe2` gimbal, `0xe3` AI +/// +/// The frame is zero-padded to the 60 byte extension unit buffer. +/// The camera does not validate the sequence number. +const FRAME_HEADER_LENGTH: u8 = 12; +const FRAME_TYPE_SET: u8 = 0x10; + +/// CRC-16/USB: reflected polynomial 0xA001, init 0xFFFF, final xor 0xFFFF. +fn crc16_usb(bytes: &[u8]) -> u16 { + let mut crc: u16 = 0xFFFF; + + for byte in bytes { + crc ^= *byte as u16; + + for _ in 0..8 { + crc = if crc & 1 != 0 { + (crc >> 1) ^ 0xA001 + } else { + crc >> 1 + }; + } + } + + crc ^ 0xFFFF +} + +/// Builds a legacy frame for the given route, command and payload. +/// +/// # Parameters +/// - `route`: the addressed subsystem, e.g. `0xe1` for the camera itself +/// - `command`: the two byte command id, big-endian +/// - `sequence_nr`: echoed by the camera; it is not validated, so any value works +/// - `payload`: command arguments, may be empty and at most 48 bytes, since the +/// frame is padded to the 60 byte extension unit buffer +pub fn command_legacy(route: u8, command: [u8; 2], sequence_nr: u16, payload: &[u8]) -> [u8; 60] { + debug_assert!( + payload.len() <= 60 - FRAME_HEADER_LENGTH as usize, + "payload does not fit into the extension unit buffer" + ); + + let length = FRAME_HEADER_LENGTH + payload.len() as u8; + let mut frame = [0u8; 60]; + + frame[0] = 0xaa; + frame[1] = 0x00; + frame[2] = length; + frame[3] = FRAME_TYPE_SET; + frame[4..6].copy_from_slice(&sequence_nr.to_be_bytes()); + // frame[6..8] stays zeroed while the checksum is calculated over it + frame[8] = 0x00; + frame[9] = route; + frame[10..12].copy_from_slice(&command); + frame[FRAME_HEADER_LENGTH as usize..length as usize].copy_from_slice(payload); + + let checksum = crc16_usb(&frame[..length as usize]); + frame[6..8].copy_from_slice(&checksum.to_be_bytes()); + + frame +} + +#[cfg(test)] +mod tests { + mod unit { + use crate::libs::camera::command_legacy::{command_legacy, crc16_usb}; + + #[test] + fn crc_of_the_check_string() { + // The CRC-16/USB check value for "123456789" is 0xB4C8. + assert_eq!(crc16_usb(b"123456789"), 0xB4C8); + } + + /// Both frames were captured from the vendor app driving a Tiny 4K and + /// replayed successfully against the real camera. + #[test] + fn reproduces_the_captured_wake_frame() { + let frame = command_legacy(0xe1, [0x13, 0xc2], 0x35, &[0x01, 0x01]); + + assert_eq!( + frame[..14], + [ + 0xaa, 0x00, 0x0e, 0x10, 0x00, 0x35, 0x2c, 0xf1, 0x00, 0xe1, 0x13, 0xc2, 0x01, + 0x01 + ] + ); + } + + #[test] + fn reproduces_the_captured_sleep_frame() { + let frame = command_legacy(0xe1, [0x13, 0xc2], 0x34, &[0x01, 0x03]); + + assert_eq!( + frame[..14], + [ + 0xaa, 0x00, 0x0e, 0x10, 0x00, 0x34, 0x7d, 0x7d, 0x00, 0xe1, 0x13, 0xc2, 0x01, + 0x03 + ] + ); + } + + #[test] + fn pads_the_frame_to_the_full_buffer() { + let frame = command_legacy(0xe3, [0x30, 0x3a], 0x01, &[]); + + assert_eq!(frame.len(), 60, "frame fills the extension unit buffer"); + assert_eq!(frame[2], 0x0c, "a frame without payload is 12 bytes long"); + assert!( + frame[12..].iter().all(|byte| *byte == 0), + "everything after the frame is zero padding" + ); + } + } +} diff --git a/src/libs/camera/commands/mod.rs b/src/libs/camera/commands/mod.rs index 358824b..660a34c 100644 --- a/src/libs/camera/commands/mod.rs +++ b/src/libs/camera/commands/mod.rs @@ -6,6 +6,7 @@ mod exposure_mode_type; mod goto_preset_position; mod hdr_mode; mod sleep; +mod sleep_legacy; mod tracking_speed; pub use ai_mode::*; @@ -14,4 +15,5 @@ pub use exposure_mode_type::*; pub use goto_preset_position::*; pub use hdr_mode::*; pub use sleep::*; +pub use sleep_legacy::*; pub use tracking_speed::*; diff --git a/src/libs/camera/commands/sleep_legacy.rs b/src/libs/camera/commands/sleep_legacy.rs new file mode 100644 index 0000000..a684e7d --- /dev/null +++ b/src/libs/camera/commands/sleep_legacy.rs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: EUPL-1.2 + +use crate::SleepMode; +use crate::libs::camera::command_legacy::command_legacy; +use crate::libs::errors::T4lError; + +/// Sleep and wake for the OBSBOT Tiny 4K (#72). +/// +/// The Tiny 4K accepts the Tiny 2 sleep frame on USB but ignores it, so it needs +/// its own command built from the legacy frame. `0x13c2` is the device run status; +/// value `1` runs the camera and `3` sends it to sleep. +pub struct LegacySleepCommand; + +impl LegacySleepCommand { + const ROUTE_CAMERA: u8 = 0xe1; + const COMMAND_DEV_RUN_STATUS: [u8; 2] = [0x13, 0xc2]; + + /// Sequence numbers of the frames this was reverse engineered from. + /// The camera echoes them but does not validate them. + const SEQUENCE_NR_AWAKE: u16 = 0x35; + const SEQUENCE_NR_SLEEP: u16 = 0x34; + + /// Fixed first payload byte of a device run status frame. + const DEV_RUN_STATUS_PAYLOAD_PREFIX: u8 = 0x01; + + const DEV_RUN_STATUS_RUN: u8 = 0x01; + const DEV_RUN_STATUS_SLEEP: u8 = 0x03; + + pub fn build(mode: SleepMode) -> Result<[u8; 60], T4lError> { + let (sequence_nr, dev_run_status) = match mode { + SleepMode::Awake => (Self::SEQUENCE_NR_AWAKE, Self::DEV_RUN_STATUS_RUN), + SleepMode::Sleep => (Self::SEQUENCE_NR_SLEEP, Self::DEV_RUN_STATUS_SLEEP), + SleepMode::Unknown => return Err(T4lError::InvalidSetting), + }; + + Ok(command_legacy( + Self::ROUTE_CAMERA, + Self::COMMAND_DEV_RUN_STATUS, + sequence_nr, + &[Self::DEV_RUN_STATUS_PAYLOAD_PREFIX, dev_run_status], + )) + } +} + +#[cfg(test)] +mod tests { + use crate::{LegacySleepCommand, SleepMode}; + use assertables::assert_ok; + use test_case::test_case; + + #[test_case(SleepMode::Awake, 0x01; "awake sets the run status to 1")] + #[test_case(SleepMode::Sleep, 0x03; "sleep sets the run status to 3")] + fn sleep_mode(mode: SleepMode, expected_dev_run_status: u8) { + let command_option = LegacySleepCommand::build(mode); + + assert_ok!(&command_option, "sleep mode command should be built"); + + let command = command_option.unwrap(); + assert_eq!( + command[8..14], + [0x00, 0xe1, 0x13, 0xc2, 0x01, expected_dev_run_status], + "Frame is routed to the camera and sets the device run status" + ); + } + + #[test] + fn unknown_sleep_mode() { + assert!( + LegacySleepCommand::build(SleepMode::Unknown).is_err(), + "an unknown sleep mode is not a valid setting" + ); + } +} diff --git a/src/libs/camera/enums.rs b/src/libs/camera/enums.rs index 4af36fe..3ea5c3a 100644 --- a/src/libs/camera/enums.rs +++ b/src/libs/camera/enums.rs @@ -20,6 +20,28 @@ impl Display for SleepMode { } } +/// The camera model a [`crate::Camera`] is talking to. +/// +/// The Tiny 4K speaks an older protocol than the Tiny 2 and needs different +/// commands for some functions (#72). Unknown hints are treated as a Tiny 2, +/// which keeps the behaviour of every previously supported camera unchanged. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CameraModel { + Tiny2, + Tiny4K, +} + +impl CameraModel { + /// Derives the model from the V4L2 card name hint the camera was opened with. + pub fn from_hint(hint: &str) -> Self { + if hint.contains("Tiny 4K") { + CameraModel::Tiny4K + } else { + CameraModel::Tiny2 + } + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum AIMode { NoTracking, @@ -94,6 +116,19 @@ pub enum ExposureModeType { #[cfg(test)] mod tests { mod unit { + mod camera_model { + use crate::CameraModel; + use test_case::test_case; + + #[test_case("OBSBOT Tiny 4K", CameraModel::Tiny4K; "tiny 4k")] + #[test_case("OBSBOT Tiny 2", CameraModel::Tiny2; "tiny 2")] + #[test_case("OBSBOT Tiny 2 Lite", CameraModel::Tiny2; "tiny 2 lite")] + #[test_case("Something else", CameraModel::Tiny2; "unknown hints default to tiny 2")] + fn model_from_hint(hint: &str, expected: CameraModel) { + assert_eq!(CameraModel::from_hint(hint), expected); + } + } + mod display { mod sleep_mode { use crate::SleepMode; diff --git a/src/libs/camera/mod.rs b/src/libs/camera/mod.rs index 0a61e47..5092c5e 100644 --- a/src/libs/camera/mod.rs +++ b/src/libs/camera/mod.rs @@ -2,6 +2,7 @@ mod camera; mod command02; +mod command_legacy; mod commands; mod enums; mod status; @@ -9,6 +10,7 @@ mod transport; pub use camera::Camera; pub use camera::Tiny2Camera; +pub use command_legacy::command_legacy; pub use command02::command02; pub use commands::*; pub use enums::*; diff --git a/src/libs/camera/status.rs b/src/libs/camera/status.rs index 3a5c9af..02a0436 100644 --- a/src/libs/camera/status.rs +++ b/src/libs/camera/status.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: EUPL-1.2 -use crate::libs::camera::enums::{AIMode, SleepMode, TrackingSpeed}; +use crate::libs::camera::enums::{AIMode, CameraModel, SleepMode, TrackingSpeed}; pub struct CameraStatus { pub awake: SleepMode, @@ -11,8 +11,20 @@ pub struct CameraStatus { impl CameraStatus { pub fn decode(bytes: &[u8]) -> Self { + Self::decode_for(CameraModel::Tiny2, bytes) + } + + /// Decodes the status buffer for a specific model. + /// + /// Only the sleep state differs so far: the Tiny 4K reports its device run + /// status in byte `0x09`, while byte `0x02` — where the Tiny 2 keeps its sleep + /// flag — is a reserved field that is always zero on the 4K (#72). + pub fn decode_for(model: CameraModel, bytes: &[u8]) -> Self { CameraStatus { - awake: Self::decode_sleep_mode(bytes), + awake: match model { + CameraModel::Tiny2 => Self::decode_sleep_mode(bytes), + CameraModel::Tiny4K => Self::decode_sleep_mode_tiny_4k(bytes), + }, ai_mode: Self::decode_ai_mode(bytes), speed: Self::decode_tracking_speed(bytes), hdr_on: Self::decode_hdr_on(bytes), @@ -27,6 +39,17 @@ impl CameraStatus { } } + /// Device run status of the Tiny 4K: `1` running, `3` asleep, `4` privacy mode. + /// + /// Both `3` and `4` stop the video stream, so both are reported as asleep. + fn decode_sleep_mode_tiny_4k(bytes: &[u8]) -> SleepMode { + match bytes[0x09] { + 1 => SleepMode::Awake, + 3 | 4 => SleepMode::Sleep, + _ => SleepMode::Unknown, + } + } + fn decode_ai_mode(bytes: &[u8]) -> AIMode { let m = bytes[0x18]; let n = bytes[0x1c]; diff --git a/src/libs/camera/transport.rs b/src/libs/camera/transport.rs index eaf347c..90afcfc 100644 --- a/src/libs/camera/transport.rs +++ b/src/libs/camera/transport.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: EUPL-1.2 use crate::CameraStatus; +use crate::libs::camera::enums::CameraModel; use crate::libs::errors::T4lError; use crate::libs::usbio::{ CameraHandle, UVC_GET_CUR, UVC_GET_LEN, UVC_SET_CUR, UvcUsbIo, open_camera, @@ -134,7 +135,11 @@ impl CameraTransport { /// Err(e) => eprintln!("Failed to get camera status: {:?}", e), /// } /// ``` - pub fn get_status(&self, debugging: bool) -> Result { + pub fn get_status( + &self, + debugging: bool, + model: CameraModel, + ) -> Result { let mut data: [u8; 60] = [0u8; 60]; self.get_cur(0x2, 0x6, &mut data) .map_err(|x| T4lError::USBIOError(x.0))?; @@ -143,7 +148,7 @@ impl CameraTransport { println!("Current state: {:?} {:}", data, hex::encode(&data)); } - Ok(CameraStatus::decode(&data)) + Ok(CameraStatus::decode_for(model, &data)) } /// Dumps the current state of the 0x2, 0x6 data to the console in hexadecimal format. From 9e6aa1848b45986bb9240c7aa81692770f4df288 Mon Sep 17 00:00:00 2001 From: hutzelmann <18736645+hutzelmann@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:52:40 +0200 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20#72:=20AI=20tracking=20for=20th?= =?UTF-8?q?e=20OBSBOT=20Tiny=204K?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tiny 4K does not understand the Tiny 2 AI-mode setting. It splits the same function over three legacy frames: enable the AI engine, select the framing mode and select a target. Without the target selection the camera never starts following anyone. The frames also have to be paced. Sent back to back they are dropped and nothing happens at all; in captures of the vendor app no two legacy frames are ever closer than about 50 ms, so the sequence is sent with a 100 ms gap. set_ai_mode dispatches on the detected model, so the Tiny 2 path is unchanged. The 4K knows standard and headroom framing but not the other Tiny 2 modes, it has no tracking speed setting at all, and it takes manual exposure only through the standard UVC controls. Rather than accepting those commands and doing nothing, the CLI and the GUI now report that the camera does not have them. For the same reason info no longer prints an AI mode and a tracking speed that were decoded from bytes the 4K leaves at zero. Verified on a Tiny 4K: with tracking off the gimbal ignores a person moving across the frame, with tracking on it follows them by 18 degrees. Co-Authored-By: Claude Opus 5 --- README.md | 26 ++-- src/cli/main.rs | 108 ++++++++++++--- src/gui/main.rs | 15 ++ src/libs/camera/camera.rs | 57 +++++++- src/libs/camera/commands/ai_mode_legacy.rs | 154 +++++++++++++++++++++ src/libs/camera/commands/mod.rs | 2 + src/libs/camera/status.rs | 7 +- src/locales/de.json | 8 +- src/locales/en.json | 8 +- 9 files changed, 343 insertions(+), 42 deletions(-) create mode 100644 src/libs/camera/commands/ai_mode_legacy.rs diff --git a/README.md b/README.md index b7f7e1b..ac9c828 100644 --- a/README.md +++ b/README.md @@ -38,18 +38,20 @@ which itself is substantially based on [samliddicott's meet4k package](https://g Tiny4Linux is developed and tested with the **OBSBOT Tiny 2**. If no Tiny 2 is connected, an **OBSBOT Tiny 4K** is used instead ([#72](https://github.com/OpenFoxes/Tiny4Linux/issues/72)). -The Tiny 4K only understands a part of the Tiny 2 commands: - -| Feature | OBSBOT Tiny 4K | -| ------------------------------- | ------------------------------------------------------------ | -| HDR on/off | ✅ works | -| Exposure mode `face` / `global` | ✅ works | -| Sleep / wake | ✅ works, using the older protocol of the Tiny 4K | -| Status (`info`) | ⚠️ HDR and sleep state are reported, the rest shows defaults | -| Tracking modes | ❌ no effect, the Tiny 4K uses different commands | -| Tracking speed | ❌ the Tiny 4K has no tracking speed setting | -| Preset positions | ❌ no effect, the Tiny 4K has no single recall command | -| Exposure mode `manual` | ❌ no effect | +The Tiny 4K speaks an older protocol than the Tiny 2, so it understands a part of the commands +directly, a part only through its own commands, and some not at all: + +| Feature | OBSBOT Tiny 4K | +| ----------------------------------------- | --------------------------------------------------------------------- | +| HDR on/off | ✅ works | +| Exposure mode `face` / `global` | ✅ works | +| Sleep / wake | ✅ works, using the older protocol of the Tiny 4K | +| Tracking `static`, `normal`, `upper-body` | ✅ works, using the older protocol of the Tiny 4K | +| Status (`info`) | ⚠️ sleep state and HDR are reported, the tracking mode cannot be read | +| Other tracking modes | ❌ the Tiny 4K does not have them, this is reported | +| Tracking speed | ❌ the Tiny 4K has no such setting, this is reported | +| Preset positions | ❌ the Tiny 4K has no recall command | +| Exposure mode `manual` | ❌ use the V4L2 exposure controls instead | The Tiny 4K ignores settings while it is in standby (about two minutes without a video stream). Start a video stream first, then the settings are applied immediately. diff --git a/src/cli/main.rs b/src/cli/main.rs index f99fb3a..fa35e40 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -140,7 +140,15 @@ fn main() { println!("{}:", t!("shared.info.camera_status")); println!(" 💤 {}: {}", t!("shared.info.sleep_mode"), info.awake); println!(" 🤖 {}: {}", t!("shared.info.ai_mode"), info.ai_mode); - println!(" 🏃 {}: {}", t!("shared.info.tracking_speed"), info.speed); + if camera.supports_tracking_speed() { + println!(" 🏃 {}: {}", t!("shared.info.tracking_speed"), info.speed); + } else { + println!( + " 🏃 {}: {}", + t!("shared.info.tracking_speed"), + t!("shared.info.not_available") + ); + } println!(" 💐 {}: {}", t!("shared.info.hdr"), info.hdr_on); } } @@ -216,6 +224,26 @@ fn evaluate_sleep_arg(state: Option, camera: Camera) { } } +/// Applies an AI tracking mode, or reports that the connected camera does not have it. +/// +/// The Tiny 4K only knows a part of the Tiny 2 modes, and silently ignoring the +/// rest is what this whole change is trying to get rid of (#72). +fn apply_ai_mode(camera: &Camera, mode: AIMode, message: &str) { + if !camera.supports_ai_mode(mode) { + eprintln!("{}", t!("shared.errors.unsupported_tracking_mode")); + std::process::exit(1); + } + + println!("{message}"); + camera.set_ai_mode(mode).unwrap(); +} + +/// Reports that the connected camera does not have a feature, and exits. +fn unsupported(message: &str) -> ! { + eprintln!("{message}"); + std::process::exit(1); +} + fn evaluate_tracking_arg(tracking_mode: Option, camera: Camera) { let response_setting_to = t!("cli.tracking_mode.response_setting_to"); let static_mode = t!("cli.tracking_mode.static"); @@ -231,44 +259,74 @@ fn evaluate_tracking_arg(tracking_mode: Option, camera: Camera) { match tracking_mode { Some(TrackingArg::Static) => { - println!("{} {}", response_setting_to, static_mode); - camera.set_ai_mode(AIMode::NoTracking).unwrap(); + apply_ai_mode( + &camera, + AIMode::NoTracking, + &format!("{} {}", response_setting_to, static_mode), + ); } Some(TrackingArg::Normal) => { - println!("{} {}", response_setting_to, normal); - camera.set_ai_mode(AIMode::NormalTracking).unwrap(); + apply_ai_mode( + &camera, + AIMode::NormalTracking, + &format!("{} {}", response_setting_to, normal), + ); } Some(TrackingArg::CloseUp) => { - println!("{} {}", response_setting_to, close_up); - camera.set_ai_mode(AIMode::CloseUp).unwrap(); + apply_ai_mode( + &camera, + AIMode::CloseUp, + &format!("{} {}", response_setting_to, close_up), + ); } Some(TrackingArg::UpperBody) => { - println!("{} {}", response_setting_to, upper_body); - camera.set_ai_mode(AIMode::UpperBody).unwrap(); + apply_ai_mode( + &camera, + AIMode::UpperBody, + &format!("{} {}", response_setting_to, upper_body), + ); } Some(TrackingArg::Headless) => { - println!("{} {}", response_setting_to, headless); - camera.set_ai_mode(AIMode::Headless).unwrap(); + apply_ai_mode( + &camera, + AIMode::Headless, + &format!("{} {}", response_setting_to, headless), + ); } Some(TrackingArg::LowerBody) => { - println!("{} {}", response_setting_to, lower_body); - camera.set_ai_mode(AIMode::LowerBody).unwrap(); + apply_ai_mode( + &camera, + AIMode::LowerBody, + &format!("{} {}", response_setting_to, lower_body), + ); } Some(TrackingArg::Desk) => { - println!("{} {}", response_setting_to, desk); - camera.set_ai_mode(AIMode::DeskMode).unwrap(); + apply_ai_mode( + &camera, + AIMode::DeskMode, + &format!("{} {}", response_setting_to, desk), + ); } Some(TrackingArg::Whiteboard) => { - println!("{} {}", response_setting_to, whiteboard); - camera.set_ai_mode(AIMode::Whiteboard).unwrap(); + apply_ai_mode( + &camera, + AIMode::Whiteboard, + &format!("{} {}", response_setting_to, whiteboard), + ); } Some(TrackingArg::Hand) => { - println!("{} {}", response_setting_to, hand); - camera.set_ai_mode(AIMode::Hand).unwrap(); + apply_ai_mode( + &camera, + AIMode::Hand, + &format!("{} {}", response_setting_to, hand), + ); } Some(TrackingArg::Group) => { - println!("{} {}", response_setting_to, group); - camera.set_ai_mode(AIMode::Group).unwrap(); + apply_ai_mode( + &camera, + AIMode::Group, + &format!("{} {}", response_setting_to, group), + ); } None => { let options = [ @@ -331,6 +389,10 @@ fn evaluate_tracking_arg(tracking_mode: Option, camera: Camera) { } fn evaluate_speed_arg(speed: Option, camera: Camera) { + if !camera.supports_tracking_speed() { + unsupported(&t!("shared.errors.unsupported_tracking_speed")); + } + match speed { Some(TrackingSpeedArg::Standard) => { println!("{}", t!("cli.tracking_speed.response_to_standard")); @@ -447,6 +509,10 @@ fn evaluate_hdr_arg(hdr_mode: Option, camera: Camera) { fn evaluate_exposure_arg(exposure_mode: Option, camera: Camera) { match exposure_mode { Some(ExposureArg::Manual) => { + if !camera.supports_manual_exposure() { + unsupported(&t!("shared.errors.unsupported_manual_exposure")); + } + println!("{}", t!("cli.exposure.response_to_manual")); camera .set_exposure_mode(tiny4linux::ExposureMode::Manual) diff --git a/src/gui/main.rs b/src/gui/main.rs index 1b1145d..98f31b6 100644 --- a/src/gui/main.rs +++ b/src/gui/main.rs @@ -127,11 +127,21 @@ impl MainPanel { Task::none() } Message::ChangeTracking(tracking_type) => { + if !camera.supports_ai_mode(tracking_type) { + eprintln!("{}", t!("shared.errors.unsupported_tracking_mode")); + return Task::none(); + } + self.tracking = tracking_type; camera.set_ai_mode(tracking_type).unwrap(); Task::none() } Message::ChangeTrackingSpeed(new_speed) => { + if !camera.supports_tracking_speed() { + eprintln!("{}", t!("shared.errors.unsupported_tracking_speed")); + return Task::none(); + } + self.tracking_speed = new_speed; camera.set_tracking_speed(new_speed).unwrap(); Task::none() @@ -149,6 +159,11 @@ impl MainPanel { Task::none() } Message::ChangeExposure(mode) => { + if mode == ExposureMode::Manual && !camera.supports_manual_exposure() { + eprintln!("{}", t!("shared.errors.unsupported_manual_exposure")); + return Task::none(); + } + camera.set_exposure_mode(mode).unwrap(); Task::none() } diff --git a/src/libs/camera/camera.rs b/src/libs/camera/camera.rs index 6e8bf94..22c1150 100644 --- a/src/libs/camera/camera.rs +++ b/src/libs/camera/camera.rs @@ -6,9 +6,18 @@ use crate::libs::camera::transport::CameraTransport; use crate::libs::errors::T4lError; use crate::{ AIModeCommand, ExposureModeCommand, ExposureModeTypeCommand, GotoPresetPositionCommand, - HdrModeCommand, LegacySleepCommand, SleepCommand, TrackingSpeedCommand, + HdrModeCommand, LegacyAiModeCommand, LegacySleepCommand, SleepCommand, TrackingSpeedCommand, }; use errno::Errno; +use std::thread::sleep; +use std::time::Duration; + +/// Pause between two legacy frames sent in one sequence. +/// +/// The Tiny 4K drops frames that arrive back to back. In captures of the vendor +/// app no two legacy frames are ever closer than about 50 ms, with a median of +/// roughly 95 ms, so the sequence is paced accordingly (#72). +const LEGACY_FRAME_GAP: Duration = Duration::from_millis(100); /// Device hints tried in this order by [`Camera::detect`]. /// @@ -57,6 +66,31 @@ impl Camera { self.model } + /// Whether this camera has the given AI tracking mode. + /// + /// The Tiny 4K only knows a part of the Tiny 2 modes (#72). + pub fn supports_ai_mode(&self, mode: AIMode) -> bool { + match self.model { + CameraModel::Tiny2 => true, + CameraModel::Tiny4K => LegacyAiModeCommand::supports(mode), + } + } + + /// Whether this camera has a tracking speed setting. + /// + /// The Tiny 4K has none: the setting does not exist in its protocol. + pub fn supports_tracking_speed(&self) -> bool { + self.model == CameraModel::Tiny2 + } + + /// Whether this camera takes the manual exposure mode over this interface. + /// + /// The Tiny 4K drives manual exposure through the standard UVC camera + /// terminal instead, which is reachable via V4L2. + pub fn supports_manual_exposure(&self) -> bool { + self.model == CameraModel::Tiny2 + } + pub fn dump(&self) -> Result<(), Errno> { self.transport.dump() } @@ -100,10 +134,25 @@ impl Tiny2Camera for Camera { Ok(self.get_status()?.awake) } + /// Sends the AI tracking command matching the detected model. + /// + /// The Tiny 2 takes a single setting on selector 6. The Tiny 4K needs a + /// sequence of legacy frames on selector 2 instead (#72). fn set_ai_mode(&self, mode: AIMode) -> Result<(), T4lError> { - let cmd = AIModeCommand::build(mode)?; - - self.send_cmd(0x2, 0x6, &cmd) + match self.model { + CameraModel::Tiny2 => self.send_cmd(0x2, 0x6, &AIModeCommand::build(mode)?), + CameraModel::Tiny4K => { + for (index, frame) in LegacyAiModeCommand::build(mode)?.iter().enumerate() { + if index > 0 { + sleep(LEGACY_FRAME_GAP); + } + + self.send_cmd(0x2, 0x2, frame)?; + } + + Ok(()) + } + } } fn get_ai_mode(&self) -> Result { diff --git a/src/libs/camera/commands/ai_mode_legacy.rs b/src/libs/camera/commands/ai_mode_legacy.rs new file mode 100644 index 0000000..6a31452 --- /dev/null +++ b/src/libs/camera/commands/ai_mode_legacy.rs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: EUPL-1.2 + +use crate::AIMode; +use crate::libs::camera::command_legacy::command_legacy; +use crate::libs::errors::T4lError; + +/// AI tracking for the OBSBOT Tiny 4K (#72). +/// +/// The Tiny 4K does not understand the Tiny 2 AI-mode setting on selector 6. It +/// splits the same functionality over three legacy frames on the AI route `0xe3`: +/// +/// - `0x3051` enables or disables the AI engine +/// - `0x3091` selects the framing mode +/// - `0x3067` selects a target, `0x3066` releases it +/// +/// Enabling only the AI engine is not enough — the camera follows a person once a +/// target has been selected, which is what the vendor app's "lock" button does. +pub struct LegacyAiModeCommand; + +impl LegacyAiModeCommand { + const ROUTE_AI: u8 = 0xe3; + const COMMAND_AI_ENABLED: [u8; 2] = [0x30, 0x51]; + const COMMAND_TRACKING_MODE: [u8; 2] = [0x30, 0x91]; + const COMMAND_TARGET_DESELECT: [u8; 2] = [0x30, 0x66]; + const COMMAND_TARGET_SELECT: [u8; 2] = [0x30, 0x67]; + + /// Sub index of the AI enable flag inside the `0x3051` payload. + const AI_ENABLED_SUB_INDEX: u8 = 0x00; + /// The `0x3067` payload is a fixed byte, not a boolean. + const TARGET_SELECT_PAYLOAD: u8 = 0x00; + + const TRACKING_MODE_STANDARD: u8 = 0x00; + const TRACKING_MODE_HEADROOM: u8 = 0x01; + + /// Whether the Tiny 4K offers the given mode at all. + /// + /// The 4K knows standard, headroom and motion framing. It has none of the + /// Tiny 2 modes such as desk, whiteboard or hand, and there is no motion + /// mode in [`AIMode`] to map its third value to. + pub fn supports(mode: AIMode) -> bool { + matches!( + mode, + AIMode::NoTracking | AIMode::NormalTracking | AIMode::UpperBody + ) + } + + /// Builds the frames for the given mode, to be sent in order. + pub fn build(mode: AIMode) -> Result, T4lError> { + let tracking_mode = match mode { + AIMode::NoTracking => { + return Ok(vec![ + command_legacy(Self::ROUTE_AI, Self::COMMAND_TARGET_DESELECT, 1, &[]), + command_legacy( + Self::ROUTE_AI, + Self::COMMAND_AI_ENABLED, + 2, + &[Self::AI_ENABLED_SUB_INDEX, 0x00], + ), + ]); + } + AIMode::NormalTracking => Self::TRACKING_MODE_STANDARD, + AIMode::UpperBody => Self::TRACKING_MODE_HEADROOM, + _ => return Err(T4lError::InvalidSetting), + }; + + Ok(vec![ + command_legacy( + Self::ROUTE_AI, + Self::COMMAND_AI_ENABLED, + 3, + &[Self::AI_ENABLED_SUB_INDEX, 0x01], + ), + command_legacy( + Self::ROUTE_AI, + Self::COMMAND_TRACKING_MODE, + 4, + &[tracking_mode], + ), + command_legacy( + Self::ROUTE_AI, + Self::COMMAND_TARGET_SELECT, + 5, + &[Self::TARGET_SELECT_PAYLOAD], + ), + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::{AIMode, LegacyAiModeCommand}; + use test_case::test_case; + + #[test] + fn tracking_off_releases_the_target_and_disables_the_ai() { + let frames = LegacyAiModeCommand::build(AIMode::NoTracking).unwrap(); + + assert_eq!( + frames.len(), + 2, + "target is released before the AI is turned off" + ); + assert_eq!( + frames[0][9..12], + [0xe3, 0x30, 0x66], + "first frame releases the target" + ); + assert_eq!( + frames[1][9..14], + [0xe3, 0x30, 0x51, 0x00, 0x00], + "second frame disables the AI engine" + ); + } + + #[test_case(AIMode::NormalTracking, 0x00; "normal tracking uses the standard framing")] + #[test_case(AIMode::UpperBody, 0x01; "upper body uses the headroom framing")] + fn tracking_on(mode: AIMode, expected_tracking_mode: u8) { + let frames = LegacyAiModeCommand::build(mode).unwrap(); + + assert_eq!(frames.len(), 3, "enable, mode and target selection"); + assert_eq!( + frames[0][9..14], + [0xe3, 0x30, 0x51, 0x00, 0x01], + "first frame enables the AI engine" + ); + assert_eq!( + frames[1][9..13], + [0xe3, 0x30, 0x91, expected_tracking_mode], + "second frame selects the framing mode" + ); + assert_eq!( + frames[2][9..13], + [0xe3, 0x30, 0x67, 0x00], + "third frame selects a target so the camera starts following" + ); + } + + #[test_case(AIMode::CloseUp; "close up")] + #[test_case(AIMode::DeskMode; "desk mode")] + #[test_case(AIMode::Whiteboard; "whiteboard")] + #[test_case(AIMode::Unknown; "unknown")] + fn modes_the_tiny_4k_does_not_have(mode: AIMode) { + assert!(!LegacyAiModeCommand::supports(mode)); + assert!(LegacyAiModeCommand::build(mode).is_err()); + } + + #[test_case(AIMode::NoTracking; "no tracking")] + #[test_case(AIMode::NormalTracking; "normal tracking")] + #[test_case(AIMode::UpperBody; "upper body")] + fn supported_modes_build(mode: AIMode) { + assert!(LegacyAiModeCommand::supports(mode)); + assert!(LegacyAiModeCommand::build(mode).is_ok()); + } +} diff --git a/src/libs/camera/commands/mod.rs b/src/libs/camera/commands/mod.rs index 660a34c..079126c 100644 --- a/src/libs/camera/commands/mod.rs +++ b/src/libs/camera/commands/mod.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: EUPL-1.2 mod ai_mode; +mod ai_mode_legacy; mod exposure_mode; mod exposure_mode_type; mod goto_preset_position; @@ -10,6 +11,7 @@ mod sleep_legacy; mod tracking_speed; pub use ai_mode::*; +pub use ai_mode_legacy::*; pub use exposure_mode::*; pub use exposure_mode_type::*; pub use goto_preset_position::*; diff --git a/src/libs/camera/status.rs b/src/libs/camera/status.rs index 02a0436..2ee0720 100644 --- a/src/libs/camera/status.rs +++ b/src/libs/camera/status.rs @@ -25,7 +25,12 @@ impl CameraStatus { CameraModel::Tiny2 => Self::decode_sleep_mode(bytes), CameraModel::Tiny4K => Self::decode_sleep_mode_tiny_4k(bytes), }, - ai_mode: Self::decode_ai_mode(bytes), + ai_mode: match model { + CameraModel::Tiny2 => Self::decode_ai_mode(bytes), + // the Tiny 4K leaves the Tiny 2 AI fields at zero, which would + // decode to a confident "no tracking" regardless of the truth + CameraModel::Tiny4K => AIMode::Unknown, + }, speed: Self::decode_tracking_speed(bytes), hdr_on: Self::decode_hdr_on(bytes), } diff --git a/src/locales/de.json b/src/locales/de.json index 23d560b..33cd7e6 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1,7 +1,10 @@ { "shared": { "errors": { - "no_camera": "Keine Kamera gefunden. Bitte überprüfen Sie die Verbindung der Kamera." + "no_camera": "Keine Kamera gefunden. Bitte überprüfen Sie die Verbindung der Kamera.", + "unsupported_tracking_mode": "Dieser Tracking-Modus wird von der angeschlossenen Kamera nicht unterstützt.", + "unsupported_tracking_speed": "Die angeschlossene Kamera hat keine Einstellung für die Tracking-Geschwindigkeit.", + "unsupported_manual_exposure": "Die angeschlossene Kamera unterstützt den manuellen Belichtungsmodus nicht." }, "options": { "hdr": { @@ -18,7 +21,8 @@ "sleep_mode": "Schlafmodus", "tracking": "Tracking", "tracking_speed": "Tempo", - "t4l_version": "T4L-Version" + "t4l_version": "T4L-Version", + "not_available": "für diese Kamera nicht verfügbar" } }, "gui": { diff --git a/src/locales/en.json b/src/locales/en.json index 85824dd..d03800d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,7 +1,10 @@ { "shared": { "errors": { - "no_camera": "No camera found. Please check the connection of the camera." + "no_camera": "No camera found. Please check the connection of the camera.", + "unsupported_tracking_mode": "This tracking mode is not supported by the connected camera.", + "unsupported_tracking_speed": "The connected camera has no tracking speed setting.", + "unsupported_manual_exposure": "The connected camera does not support the manual exposure mode." }, "options": { "hdr": { @@ -18,7 +21,8 @@ "sleep_mode": "Sleep Mode", "tracking": "Tracking", "tracking_speed": "Tracking Speed", - "t4l_version": "T4L Version" + "t4l_version": "T4L Version", + "not_available": "not available for this camera" } }, "gui": { From cb32b42740020833f09912ef92ae4dcb91d668b9 Mon Sep 17 00:00:00 2001 From: hutzelmann <18736645+hutzelmann@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:39:02 +0200 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=A8=20#72:=20Preset=20positions=20for?= =?UTF-8?q?=20the=20OBSBOT=20Tiny=204K?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tiny 4K has no command that recalls a preset. It stores the positions itself and hands the whole table out in one read on extension unit 2, selector 7: a count byte followed by one nine byte record per slot, holding the slot number, pitch and yaw in hundredths of a degree and the zoom factor. A recall is therefore a read followed by two frames: release the tracked target, because the AI would otherwise steer the gimbal straight back, and move to the stored angles. The positions themselves are still stored with OBSBOT Center; this only recalls them. goto_preset_position dispatches on the detected model, so the Tiny 2 path is unchanged. Recalling a slot the camera does not have used to panic through unwrap in both the CLI and the GUI, and now reports the problem instead. Verified on a Tiny 4K: after moving the gimbal to -9.91 degrees, "t4l preset 1" returns it to 21.05 degrees against a stored 21.16, and "t4l preset 2" to the default position. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/cli/main.rs | 7 +- src/gui/main.rs | 4 +- src/libs/camera/camera.rs | 34 +++- src/libs/camera/commands/mod.rs | 2 + .../camera/commands/preset_position_legacy.rs | 175 ++++++++++++++++++ src/libs/camera/transport.rs | 16 ++ src/locales/de.json | 3 +- src/locales/en.json | 3 +- 9 files changed, 237 insertions(+), 9 deletions(-) create mode 100644 src/libs/camera/commands/preset_position_legacy.rs diff --git a/README.md b/README.md index ac9c828..f030f31 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,10 @@ directly, a part only through its own commands, and some not at all: | Exposure mode `face` / `global` | ✅ works | | Sleep / wake | ✅ works, using the older protocol of the Tiny 4K | | Tracking `static`, `normal`, `upper-body` | ✅ works, using the older protocol of the Tiny 4K | +| Preset positions | ✅ works, recalls the positions stored by OBSBOT Center | | Status (`info`) | ⚠️ sleep state and HDR are reported, the tracking mode cannot be read | | Other tracking modes | ❌ the Tiny 4K does not have them, this is reported | | Tracking speed | ❌ the Tiny 4K has no such setting, this is reported | -| Preset positions | ❌ the Tiny 4K has no recall command | | Exposure mode `manual` | ❌ use the V4L2 exposure controls instead | The Tiny 4K ignores settings while it is in standby (about two minutes without a video stream). diff --git a/src/cli/main.rs b/src/cli/main.rs index fa35e40..3462b55 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -460,9 +460,12 @@ fn evaluate_preset_arg(position_id: Option, camera: Camera) { position_id = position_id.unwrap() ), ); - camera + if camera .goto_preset_position(position_id.unwrap() - 1) - .unwrap(); + .is_err() + { + unsupported(&t!("shared.errors.unknown_preset_position")); + } } fn evaluate_hdr_arg(hdr_mode: Option, camera: Camera) { diff --git a/src/gui/main.rs b/src/gui/main.rs index 98f31b6..3e1cb70 100644 --- a/src/gui/main.rs +++ b/src/gui/main.rs @@ -150,7 +150,9 @@ impl MainPanel { self.tracking = AIMode::NoTracking; self.awake = SleepMode::Awake; camera.set_ai_mode(AIMode::NoTracking).unwrap(); - camera.goto_preset_position(new_position).unwrap(); + if camera.goto_preset_position(new_position).is_err() { + eprintln!("{}", t!("shared.errors.unknown_preset_position")); + } Task::none() } Message::ChangeHDR(new_mode) => { diff --git a/src/libs/camera/camera.rs b/src/libs/camera/camera.rs index 22c1150..1222d79 100644 --- a/src/libs/camera/camera.rs +++ b/src/libs/camera/camera.rs @@ -6,7 +6,8 @@ use crate::libs::camera::transport::CameraTransport; use crate::libs::errors::T4lError; use crate::{ AIModeCommand, ExposureModeCommand, ExposureModeTypeCommand, GotoPresetPositionCommand, - HdrModeCommand, LegacyAiModeCommand, LegacySleepCommand, SleepCommand, TrackingSpeedCommand, + HdrModeCommand, LegacyAiModeCommand, LegacyPresetPositionCommand, LegacySleepCommand, + SleepCommand, TrackingSpeedCommand, }; use errno::Errno; use std::thread::sleep; @@ -159,10 +160,37 @@ impl Tiny2Camera for Camera { Ok(self.get_status()?.ai_mode) } + /// Moves the camera to a stored preset position. + /// + /// The Tiny 2 has a recall command. The Tiny 4K has not: it stores the positions + /// and hands them out on request, so the recall is a read followed by an absolute + /// move (#72). Presets are stored by the vendor app; this only recalls them. fn goto_preset_position(&self, preset_nr: i8) -> Result<(), T4lError> { - let cmd = GotoPresetPositionCommand::build(preset_nr)?; + match self.model { + CameraModel::Tiny2 => { + self.send_cmd(0x2, 0x2, &GotoPresetPositionCommand::build(preset_nr)?) + } + CameraModel::Tiny4K => { + let slot = u8::try_from(preset_nr).map_err(|_| { + T4lError::UnsupportedIntValue("preset position".to_string(), preset_nr as i32) + })?; + let table = self.transport.get_preset_positions(self.debugging)?; + let preset = LegacyPresetPositionCommand::find(&table, slot)?; + + for (index, frame) in LegacyPresetPositionCommand::build(&preset) + .iter() + .enumerate() + { + if index > 0 { + sleep(LEGACY_FRAME_GAP); + } - self.send_cmd(0x2, 0x2, &cmd) + self.send_cmd(0x2, 0x2, frame)?; + } + + Ok(()) + } + } } fn get_tracking_speed(&self) -> Result { diff --git a/src/libs/camera/commands/mod.rs b/src/libs/camera/commands/mod.rs index 079126c..fa46a5d 100644 --- a/src/libs/camera/commands/mod.rs +++ b/src/libs/camera/commands/mod.rs @@ -6,6 +6,7 @@ mod exposure_mode; mod exposure_mode_type; mod goto_preset_position; mod hdr_mode; +mod preset_position_legacy; mod sleep; mod sleep_legacy; mod tracking_speed; @@ -16,6 +17,7 @@ pub use exposure_mode::*; pub use exposure_mode_type::*; pub use goto_preset_position::*; pub use hdr_mode::*; +pub use preset_position_legacy::*; pub use sleep::*; pub use sleep_legacy::*; pub use tracking_speed::*; diff --git a/src/libs/camera/commands/preset_position_legacy.rs b/src/libs/camera/commands/preset_position_legacy.rs new file mode 100644 index 0000000..8069b99 --- /dev/null +++ b/src/libs/camera/commands/preset_position_legacy.rs @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: EUPL-1.2 + +use crate::libs::camera::command_legacy::command_legacy; +use crate::libs::errors::T4lError; + +/// A preset position as the OBSBOT Tiny 4K stores it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PresetPosition { + pub slot: u8, + /// Pitch in hundredths of a degree. + pub pitch: i16, + /// Yaw in hundredths of a degree. + pub yaw: i16, + /// Zoom factor in hundredths, so `100` is 1.00x. + pub zoom: u16, +} + +/// Preset positions for the OBSBOT Tiny 4K (#72). +/// +/// The Tiny 4K has no "recall preset" command. It stores the positions itself and +/// hands the whole table out in one read on extension unit 2, selector 7: +/// +/// ```text +/// then records of 00 00 +/// ``` +/// +/// A recall is then two frames: release the tracked target, because the AI would +/// otherwise immediately steer the gimbal back, and move to the stored angles. +pub struct LegacyPresetPositionCommand; + +impl LegacyPresetPositionCommand { + const ROUTE_AI: u8 = 0xe3; + const ROUTE_GIMBAL: u8 = 0xe2; + const COMMAND_TARGET_DESELECT: [u8; 2] = [0x30, 0x66]; + const COMMAND_SET_SPEED_POSITION: [u8; 2] = [0x20, 0x06]; + + const RECORD_LENGTH: usize = 9; + + /// Speed the gimbal travels with, in hundredths of a degree per second. + /// + /// The vendor app varies this per recall; a fixed moderate speed reproduces the + /// same movement. + const SPEED: i16 = 2000; + + /// Reads the preset table out of a selector 7 response. + pub fn parse(buffer: &[u8]) -> Vec { + let count = buffer.first().copied().unwrap_or(0) as usize; + let available = buffer.len().saturating_sub(1) / Self::RECORD_LENGTH; + + (0..count.min(available)) + .map(|index| { + let record = &buffer[1 + index * Self::RECORD_LENGTH..][..Self::RECORD_LENGTH]; + + PresetPosition { + slot: record[0], + pitch: i16::from_le_bytes([record[3], record[4]]), + yaw: i16::from_le_bytes([record[5], record[6]]), + zoom: u16::from_le_bytes([record[7], record[8]]), + } + }) + .collect() + } + + /// Picks the preset stored in the given slot. + pub fn find(buffer: &[u8], slot: u8) -> Result { + Self::parse(buffer) + .into_iter() + .find(|preset| preset.slot == slot) + .ok_or(T4lError::UnsupportedIntValue( + "preset position".to_string(), + slot as i32, + )) + } + + /// Builds the frames that move the gimbal to a stored preset, to be sent in order. + pub fn build(preset: &PresetPosition) -> Vec<[u8; 60]> { + let mut payload = Vec::with_capacity(12); + for value in [0, 0, Self::SPEED, 0, preset.pitch, preset.yaw] { + payload.extend_from_slice(&value.to_le_bytes()); + } + + vec![ + command_legacy(Self::ROUTE_AI, Self::COMMAND_TARGET_DESELECT, 6, &[]), + command_legacy( + Self::ROUTE_GIMBAL, + Self::COMMAND_SET_SPEED_POSITION, + 7, + &payload, + ), + ] + } +} + +#[cfg(test)] +mod tests { + use crate::{LegacyPresetPositionCommand, PresetPosition}; + + /// Read from a real Tiny 4K: slot 0 was stored from the vendor app, slots 1 and 2 + /// are untouched and therefore identical. + const TABLE: [u8; 28] = [ + 0x03, 0x01, 0x00, 0x00, 0xf7, 0xff, 0xfc, 0xff, 0x64, 0x00, 0x02, 0x00, 0x00, 0xf7, 0xff, + 0xfc, 0xff, 0x64, 0x00, 0x00, 0x00, 0x00, 0xc2, 0xfe, 0x44, 0x08, 0x64, 0x00, + ]; + + #[test] + fn parses_every_slot() { + let presets = LegacyPresetPositionCommand::parse(&TABLE); + + assert_eq!( + presets.len(), + 3, + "the leading byte is the number of presets" + ); + assert_eq!( + presets[2], + PresetPosition { + slot: 0, + pitch: -318, + yaw: 2116, + zoom: 100 + }, + "slot 0 keeps the position stored from the vendor app" + ); + assert_eq!(presets[0].slot, 1); + assert_eq!(presets[1].slot, 2); + assert_eq!( + (presets[0].pitch, presets[0].yaw, presets[0].zoom), + (presets[1].pitch, presets[1].yaw, presets[1].zoom), + "untouched slots hold the same default position, only the slot number differs" + ); + } + + #[test] + fn finds_a_slot_by_number() { + let preset = LegacyPresetPositionCommand::find(&TABLE, 0).unwrap(); + + assert_eq!(preset.yaw, 2116); + assert!( + LegacyPresetPositionCommand::find(&TABLE, 7).is_err(), + "a slot the camera does not have is an error, not a silent no-op" + ); + } + + #[test] + fn truncated_or_empty_tables_do_not_panic() { + assert!(LegacyPresetPositionCommand::parse(&[]).is_empty()); + assert!(LegacyPresetPositionCommand::parse(&[0x00]).is_empty()); + assert_eq!( + LegacyPresetPositionCommand::parse(&[0x09, 0x01, 0x00, 0x00]).len(), + 0, + "a count larger than the buffer is clamped" + ); + } + + #[test] + fn recall_releases_the_target_before_moving() { + let preset = LegacyPresetPositionCommand::find(&TABLE, 0).unwrap(); + let frames = LegacyPresetPositionCommand::build(&preset); + + assert_eq!(frames.len(), 2); + assert_eq!( + frames[0][9..12], + [0xe3, 0x30, 0x66], + "the AI would steer the gimbal straight back otherwise" + ); + assert_eq!(frames[1][9..12], [0xe2, 0x20, 0x06], "absolute move"); + assert_eq!( + frames[1][12..24], + [ + 0x00, 0x00, 0x00, 0x00, 0xd0, 0x07, 0x00, 0x00, 0xc2, 0xfe, 0x44, 0x08 + ], + "three speeds then three angles, little endian" + ); + } +} diff --git a/src/libs/camera/transport.rs b/src/libs/camera/transport.rs index 90afcfc..9339083 100644 --- a/src/libs/camera/transport.rs +++ b/src/libs/camera/transport.rs @@ -151,6 +151,22 @@ impl CameraTransport { Ok(CameraStatus::decode_for(model, &data)) } + /// Reads the preset position table of an OBSBOT Tiny 4K. + /// + /// The camera returns the whole table in one go on extension unit 2, + /// selector 7, see [`crate::LegacyPresetPositionCommand`] (#72). + pub fn get_preset_positions(&self, debugging: bool) -> Result<[u8; 60], T4lError> { + let mut data: [u8; 60] = [0u8; 60]; + self.get_cur(0x2, 0x7, &mut data) + .map_err(|x| T4lError::USBIOError(x.0))?; + + if debugging { + println!("Preset positions: {:}", hex::encode(data)); + } + + Ok(data) + } + /// Dumps the current state of the 0x2, 0x6 data to the console in hexadecimal format. /// /// # Returns diff --git a/src/locales/de.json b/src/locales/de.json index 33cd7e6..400fc3c 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -4,7 +4,8 @@ "no_camera": "Keine Kamera gefunden. Bitte überprüfen Sie die Verbindung der Kamera.", "unsupported_tracking_mode": "Dieser Tracking-Modus wird von der angeschlossenen Kamera nicht unterstützt.", "unsupported_tracking_speed": "Die angeschlossene Kamera hat keine Einstellung für die Tracking-Geschwindigkeit.", - "unsupported_manual_exposure": "Die angeschlossene Kamera unterstützt den manuellen Belichtungsmodus nicht." + "unsupported_manual_exposure": "Die angeschlossene Kamera unterstützt den manuellen Belichtungsmodus nicht.", + "unknown_preset_position": "Diese Preset-Position gibt es auf der angeschlossenen Kamera nicht." }, "options": { "hdr": { diff --git a/src/locales/en.json b/src/locales/en.json index d03800d..9262186 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4,7 +4,8 @@ "no_camera": "No camera found. Please check the connection of the camera.", "unsupported_tracking_mode": "This tracking mode is not supported by the connected camera.", "unsupported_tracking_speed": "The connected camera has no tracking speed setting.", - "unsupported_manual_exposure": "The connected camera does not support the manual exposure mode." + "unsupported_manual_exposure": "The connected camera does not support the manual exposure mode.", + "unknown_preset_position": "This preset position does not exist on the connected camera." }, "options": { "hdr": { From 8886c095b3518531c6dd233f9b47da5337cb6801 Mon Sep 17 00:00:00 2001 From: hutzelmann <18736645+hutzelmann@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:02:30 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=90=9B=20#72:=20Stop=20the=20GUI=20de?= =?UTF-8?q?bug=20area=20from=20taking=20the=20window=20down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Get & Dump 0x02" crashed the whole GUI on an OBSBOT Tiny 4K. That camera stalls a cold GET_CUR on extension unit 2, selector 2, dump_02 returns the resulting USB error and unwrap turned it into a panic. Selectors 6 and 7 answer normally, so only this one button was affected. The other three debug actions had the same problem for any USB error, and invalid hex input in the two command fields panicked as well. All four now report what went wrong and leave the window standing. Co-Authored-By: Claude Opus 5 --- src/gui/main.rs | 32 ++++++++++++++++++++++++++------ src/locales/de.json | 4 +++- src/locales/en.json | 4 +++- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/gui/main.rs b/src/gui/main.rs index 3e1cb70..0cb7c5c 100644 --- a/src/gui/main.rs +++ b/src/gui/main.rs @@ -184,21 +184,29 @@ impl MainPanel { Task::none() } Message::SendCommand => { - let c = hex::decode(&self.text_input).unwrap(); - camera.send_cmd(0x2, 0x6, &c).unwrap(); + report_debug_result( + hex::decode(&self.text_input) + .ok() + .map(|c| camera.send_cmd(0x2, 0x6, &c).is_ok()), + ); Task::none() } Message::SendCommand02 => { - let c = hex::decode(&self.text_input_02).unwrap(); - camera.send_cmd(0x2, 0x2, &c).unwrap(); + report_debug_result( + hex::decode(&self.text_input_02) + .ok() + .map(|c| camera.send_cmd(0x2, 0x2, &c).is_ok()), + ); Task::none() } Message::HexDump => { - camera.dump().unwrap(); + report_debug_result(Some(camera.dump().is_ok())); Task::none() } Message::HexDump02 => { - camera.dump_02().unwrap(); + // the Tiny 4K stalls a cold GET_CUR on selector 2, which used to take + // the whole window down through unwrap (#72) + report_debug_result(Some(camera.dump_02().is_ok())); Task::none() } Message::CheckCamera => Task::none(), @@ -310,3 +318,15 @@ fn main() -> iced::Result { .subscription(MainPanel::subscription) .run_with(move || MainPanel::init_state(start_mode)) } + +/// Reports the outcome of a debug-area action instead of taking the window down. +/// +/// `None` means the entered hex could not be parsed, `Some(false)` that the camera +/// rejected the request. Neither is a reason to panic in a GUI. +fn report_debug_result(outcome: Option) { + match outcome { + Some(true) => {} + Some(false) => eprintln!("{}", t!("shared.errors.debug_request_failed")), + None => eprintln!("{}", t!("shared.errors.debug_input_invalid")), + } +} diff --git a/src/locales/de.json b/src/locales/de.json index 400fc3c..5e4bdf1 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -5,7 +5,9 @@ "unsupported_tracking_mode": "Dieser Tracking-Modus wird von der angeschlossenen Kamera nicht unterstützt.", "unsupported_tracking_speed": "Die angeschlossene Kamera hat keine Einstellung für die Tracking-Geschwindigkeit.", "unsupported_manual_exposure": "Die angeschlossene Kamera unterstützt den manuellen Belichtungsmodus nicht.", - "unknown_preset_position": "Diese Preset-Position gibt es auf der angeschlossenen Kamera nicht." + "unknown_preset_position": "Diese Preset-Position gibt es auf der angeschlossenen Kamera nicht.", + "debug_request_failed": "Die Kamera hat die Anfrage abgelehnt.", + "debug_input_invalid": "Der eingegebene Wert ist kein gültiges Hexadezimal." }, "options": { "hdr": { diff --git a/src/locales/en.json b/src/locales/en.json index 9262186..5e0c63b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -5,7 +5,9 @@ "unsupported_tracking_mode": "This tracking mode is not supported by the connected camera.", "unsupported_tracking_speed": "The connected camera has no tracking speed setting.", "unsupported_manual_exposure": "The connected camera does not support the manual exposure mode.", - "unknown_preset_position": "This preset position does not exist on the connected camera." + "unknown_preset_position": "This preset position does not exist on the connected camera.", + "debug_request_failed": "The camera rejected the request.", + "debug_input_invalid": "The entered value is not valid hexadecimal." }, "options": { "hdr": { From b76ed99e3218cbd7bd190adacf26d7607f5244ba Mon Sep 17 00:00:00 2001 From: hutzelmann <18736645+hutzelmann@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:27:52 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9C=A8=20#72:=20Show=20in=20the=20GUI=20?= =?UTF-8?q?what=20the=20connected=20camera=20can=20do?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On an OBSBOT Tiny 4K the tracking speed buttons, the tracking modes that camera does not know and the manual exposure button all looked clickable, did nothing and reported the problem on stderr, where a user of a graphical application never sees it. The status panel additionally stated a tracking speed on a camera that has no such setting, the same invented value that info used to print. Those controls are now disabled and faded well below the contrast of an active button, and the panel says the setting is not available. The fade deliberately has no outline: an outlined control reads as interactive, and the active buttons beside them are solid fills, so a border would have given the unavailable control the stronger visual cue of the two. The capability queries moved from Camera to CameraModel, because the view builds its buttons from the application state and has no camera handle there. Camera delegates to the model, so nothing else changes. While no camera is connected every button stays enabled, exactly as before. Co-Authored-By: Claude Opus 5 --- src/gui/styles/button_unsupported.rs | 26 +++++ src/gui/styles/mod.rs | 1 + src/gui/ui_modules/button_exposure_mode.rs | 12 +- src/gui/ui_modules/button_tracking_mode.rs | 25 +++- src/gui/ui_modules/current_stats.rs | 32 ++++-- src/gui/ui_modules/settings_area.rs | 126 ++++++++++++++++----- src/libs/camera/camera.rs | 9 +- src/libs/camera/enums.rs | 25 ++++ 8 files changed, 202 insertions(+), 54 deletions(-) create mode 100644 src/gui/styles/button_unsupported.rs diff --git a/src/gui/styles/button_unsupported.rs b/src/gui/styles/button_unsupported.rs new file mode 100644 index 0000000..733e9ca --- /dev/null +++ b/src/gui/styles/button_unsupported.rs @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: EUPL-1.2 + +use crate::styles::colors::COLOR_BACKGROUND_SECONDARY_DARK; +use iced::widget::button::{Status, Style}; +use iced::{Background, Color, Theme}; + +/// Style for a control the connected camera does not have (#72). +/// +/// iced dims a disabled button only slightly, which on the dark theme is easy to +/// miss. This fades it much further instead. It deliberately has no outline: an +/// outlined control reads as interactive, and the active buttons next to it are +/// solid fills, so a border here would give the unavailable control the stronger +/// visual cue of the two. +pub fn button_unsupported(_: &Theme, _: Status) -> Style { + Style { + background: Some(Background::Color(Color { + a: 0.30, + ..COLOR_BACKGROUND_SECONDARY_DARK + })), + text_color: Color { + a: 0.28, + ..Color::WHITE + }, + ..Default::default() + } +} diff --git a/src/gui/styles/mod.rs b/src/gui/styles/mod.rs index d776bc0..2f2d6b3 100644 --- a/src/gui/styles/mod.rs +++ b/src/gui/styles/mod.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: EUPL-1.2 pub mod button_non_styled; +pub mod button_unsupported; pub mod colors; pub mod general_area_style; pub mod theme; diff --git a/src/gui/ui_modules/button_exposure_mode.rs b/src/gui/ui_modules/button_exposure_mode.rs index 9bcbb16..d7e0bee 100644 --- a/src/gui/ui_modules/button_exposure_mode.rs +++ b/src/gui/ui_modules/button_exposure_mode.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: EUPL-1.2 use crate::Message; +use crate::styles::button_unsupported::button_unsupported; use crate::styles::tooltip_style::tooltip_content; use iced::widget::button::secondary; use iced::widget::tooltip::Position; @@ -8,11 +9,16 @@ use iced::widget::{Container, button, container, text, tooltip}; use rust_i18n::t; use tiny4linux::ExposureMode; -pub fn button_exposure_mode(mode: ExposureMode) -> Container<'static, Message> { +/// An exposure mode button, disabled for modes the connected camera does not take. +pub fn button_exposure_mode(mode: ExposureMode, supported: bool) -> Container<'static, Message> { container(tooltip( button(text(format!("{}", mode))) - .on_press(Message::ChangeExposure(mode)) - .style(secondary), + .on_press_maybe(supported.then_some(Message::ChangeExposure(mode))) + .style(if supported { + secondary + } else { + button_unsupported + }), tooltip_content(container(text(t!( "gui.tooltips.changes_exposure", mode = mode diff --git a/src/gui/ui_modules/button_tracking_mode.rs b/src/gui/ui_modules/button_tracking_mode.rs index 5ff0f69..a69cf19 100644 --- a/src/gui/ui_modules/button_tracking_mode.rs +++ b/src/gui/ui_modules/button_tracking_mode.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: EUPL-1.2 use crate::Message; +use crate::styles::button_unsupported::button_unsupported; use crate::styles::tooltip_style::tooltip_content; use iced::widget::button::{primary, secondary}; use iced::widget::tooltip::Position; @@ -8,11 +9,21 @@ use iced::widget::{Container, button, container, text, tooltip}; use rust_i18n::t; use tiny4linux::{AIMode, TrackingSpeed}; -pub fn button_tracking_mode(mode: AIMode, current_mode: AIMode) -> Container<'static, Message> { +/// A tracking mode button. +/// +/// `supported` is false for modes the connected camera does not have, which leaves +/// the button disabled instead of letting it look clickable and do nothing (#72). +pub fn button_tracking_mode( + mode: AIMode, + current_mode: AIMode, + supported: bool, +) -> Container<'static, Message> { container(tooltip( button(text(format!("{}", mode.to_string()))) - .on_press(Message::ChangeTracking(mode)) - .style(if current_mode == mode { + .on_press_maybe(supported.then_some(Message::ChangeTracking(mode))) + .style(if !supported { + button_unsupported + } else if current_mode == mode { primary } else { secondary @@ -25,14 +36,18 @@ pub fn button_tracking_mode(mode: AIMode, current_mode: AIMode) -> Container<'st )) } +/// A tracking speed button, disabled on cameras without a tracking speed setting. pub fn button_tracking_speed( speed: TrackingSpeed, current_speed: TrackingSpeed, + supported: bool, ) -> Container<'static, Message> { container(tooltip( button(text(format!("{}", speed.to_string()))) - .on_press(Message::ChangeTrackingSpeed(speed)) - .style(if current_speed == speed { + .on_press_maybe(supported.then_some(Message::ChangeTrackingSpeed(speed))) + .style(if !supported { + button_unsupported + } else if current_speed == speed { primary } else { secondary diff --git a/src/gui/ui_modules/current_stats.rs b/src/gui/ui_modules/current_stats.rs index d4a26b2..25b5c97 100644 --- a/src/gui/ui_modules/current_stats.rs +++ b/src/gui/ui_modules/current_stats.rs @@ -130,17 +130,27 @@ pub fn current_stats(app: &MainPanel) -> Container<'static, Message> { .spacing(10), row![ text(TEXT_INDENT_LONG), - match app.tracking_speed { - TrackingSpeed::Standard => row![ - fa_icon_solid("gauge-simple"), - text(t!("display.tracking_speed.standard")) - ] - .spacing(10), - TrackingSpeed::Sport => row![ - fa_icon_solid("gauge-simple-high"), - text(t!("display.tracking_speed.sport")) - ] - .spacing(10), + // a camera without a tracking speed setting must not be shown one + if app + .camera + .as_ref() + .is_some_and(|camera| !camera.supports_tracking_speed()) + { + row![fa_icon_solid("ban"), text(t!("shared.info.not_available"))] + .spacing(10) + } else { + match app.tracking_speed { + TrackingSpeed::Standard => row![ + fa_icon_solid("gauge-simple"), + text(t!("display.tracking_speed.standard")) + ] + .spacing(10), + TrackingSpeed::Sport => row![ + fa_icon_solid("gauge-simple-high"), + text(t!("display.tracking_speed.sport")) + ] + .spacing(10), + } } ] .spacing(10), diff --git a/src/gui/ui_modules/settings_area.rs b/src/gui/ui_modules/settings_area.rs index d67689f..350a669 100644 --- a/src/gui/ui_modules/settings_area.rs +++ b/src/gui/ui_modules/settings_area.rs @@ -14,19 +14,29 @@ use iced::widget::{ }; use iced_font_awesome::fa_icon_solid; use rust_i18n::t; -use tiny4linux::{AIMode, ExposureMode, TrackingSpeed}; +use tiny4linux::{AIMode, CameraModel, ExposureMode, TrackingSpeed}; pub fn settings_area(app: &MainPanel) -> Container<'static, Message> { container( column![ presets(), horizontal_rule(8), - tracking_modes(app.window_mode == WindowMode::Widget, app.tracking), - tracking_speed(app.tracking_speed), + tracking_modes( + app.window_mode == WindowMode::Widget, + app.tracking, + app.camera.as_ref().map(|camera| camera.model()), + ), + tracking_speed( + app.tracking_speed, + app.camera.as_ref().map(|camera| camera.model()) + ), horizontal_rule(8), - row![hdr(app.hdr_on), exposure_mode()] - .spacing(10) - .align_y(Vertical::Center), + row![ + hdr(app.hdr_on), + exposure_mode(app.camera.as_ref().map(|camera| camera.model())) + ] + .spacing(10) + .align_y(Vertical::Center), ] .spacing(20), ) @@ -55,21 +65,40 @@ fn presets() -> Row<'static, Message> { ] } -fn tracking_modes(reduced: bool, current_mode: AIMode) -> Container<'static, Message> { +/// `model` is `None` while no camera is connected, in which case every button stays +/// enabled, exactly as before. +fn tracking_modes( + reduced: bool, + current_mode: AIMode, + model: Option, +) -> Container<'static, Message> { + let supported = |mode: AIMode| model.is_none_or(|model| model.supports_ai_mode(mode)); container( column![ text(format!("{}:", t!("shared.info.tracking"))), if reduced { column![ row![ - button_tracking_mode(AIMode::NoTracking, current_mode), - button_tracking_mode(AIMode::NormalTracking, current_mode), + button_tracking_mode( + AIMode::NoTracking, + current_mode, + supported(AIMode::NoTracking) + ), + button_tracking_mode( + AIMode::NormalTracking, + current_mode, + supported(AIMode::NormalTracking) + ), ] .spacing(10), row![ - button_tracking_mode(AIMode::Hand, current_mode), - button_tracking_mode(AIMode::Whiteboard, current_mode), - button_tracking_mode(AIMode::Group, current_mode), + button_tracking_mode(AIMode::Hand, current_mode, supported(AIMode::Hand)), + button_tracking_mode( + AIMode::Whiteboard, + current_mode, + supported(AIMode::Whiteboard) + ), + button_tracking_mode(AIMode::Group, current_mode, supported(AIMode::Group)), ] .spacing(10) ] @@ -78,22 +107,54 @@ fn tracking_modes(reduced: bool, current_mode: AIMode) -> Container<'static, Mes } else { column![ row![ - button_tracking_mode(AIMode::NoTracking, current_mode), - button_tracking_mode(AIMode::NormalTracking, current_mode), + button_tracking_mode( + AIMode::NoTracking, + current_mode, + supported(AIMode::NoTracking) + ), + button_tracking_mode( + AIMode::NormalTracking, + current_mode, + supported(AIMode::NormalTracking) + ), ] .spacing(10), row![ - button_tracking_mode(AIMode::CloseUp, current_mode), - button_tracking_mode(AIMode::UpperBody, current_mode), - button_tracking_mode(AIMode::Headless, current_mode), - button_tracking_mode(AIMode::LowerBody, current_mode), + button_tracking_mode( + AIMode::CloseUp, + current_mode, + supported(AIMode::CloseUp) + ), + button_tracking_mode( + AIMode::UpperBody, + current_mode, + supported(AIMode::UpperBody) + ), + button_tracking_mode( + AIMode::Headless, + current_mode, + supported(AIMode::Headless) + ), + button_tracking_mode( + AIMode::LowerBody, + current_mode, + supported(AIMode::LowerBody) + ), ] .spacing(10), row![ - button_tracking_mode(AIMode::DeskMode, current_mode), - button_tracking_mode(AIMode::Whiteboard, current_mode), - button_tracking_mode(AIMode::Hand, current_mode), - button_tracking_mode(AIMode::Group, current_mode), + button_tracking_mode( + AIMode::DeskMode, + current_mode, + supported(AIMode::DeskMode) + ), + button_tracking_mode( + AIMode::Whiteboard, + current_mode, + supported(AIMode::Whiteboard) + ), + button_tracking_mode(AIMode::Hand, current_mode, supported(AIMode::Hand)), + button_tracking_mode(AIMode::Group, current_mode, supported(AIMode::Group)), ] .spacing(10) ] @@ -107,14 +168,18 @@ fn tracking_modes(reduced: bool, current_mode: AIMode) -> Container<'static, Mes ) } -fn tracking_speed(current_speed: TrackingSpeed) -> Container<'static, Message> { +fn tracking_speed( + current_speed: TrackingSpeed, + model: Option, +) -> Container<'static, Message> { + let supported = model.is_none_or(|model| model.supports_tracking_speed()); container( column![ text(format!("{}:", t!("shared.info.tracking_speed"))), column![ row![ - button_tracking_speed(TrackingSpeed::Standard, current_speed), - button_tracking_speed(TrackingSpeed::Sport, current_speed), + button_tracking_speed(TrackingSpeed::Standard, current_speed, supported), + button_tracking_speed(TrackingSpeed::Sport, current_speed, supported), ] .spacing(10), ] @@ -138,13 +203,16 @@ fn hdr(current_mode: bool) -> Container<'static, Message> { ) } -fn exposure_mode() -> Container<'static, Message> { +fn exposure_mode(model: Option) -> Container<'static, Message> { container( column![ text(format!("{}:", t!("shared.info.exposure"))), - button_exposure_mode(ExposureMode::Manual), - button_exposure_mode(ExposureMode::Global), - button_exposure_mode(ExposureMode::Face), + button_exposure_mode( + ExposureMode::Manual, + model.is_none_or(|model| model.supports_manual_exposure()), + ), + button_exposure_mode(ExposureMode::Global, true), + button_exposure_mode(ExposureMode::Face, true), ] .align_x(Horizontal::Center) .width(Length::Fill) diff --git a/src/libs/camera/camera.rs b/src/libs/camera/camera.rs index 1222d79..018bf1e 100644 --- a/src/libs/camera/camera.rs +++ b/src/libs/camera/camera.rs @@ -71,17 +71,14 @@ impl Camera { /// /// The Tiny 4K only knows a part of the Tiny 2 modes (#72). pub fn supports_ai_mode(&self, mode: AIMode) -> bool { - match self.model { - CameraModel::Tiny2 => true, - CameraModel::Tiny4K => LegacyAiModeCommand::supports(mode), - } + self.model.supports_ai_mode(mode) } /// Whether this camera has a tracking speed setting. /// /// The Tiny 4K has none: the setting does not exist in its protocol. pub fn supports_tracking_speed(&self) -> bool { - self.model == CameraModel::Tiny2 + self.model.supports_tracking_speed() } /// Whether this camera takes the manual exposure mode over this interface. @@ -89,7 +86,7 @@ impl Camera { /// The Tiny 4K drives manual exposure through the standard UVC camera /// terminal instead, which is reachable via V4L2. pub fn supports_manual_exposure(&self) -> bool { - self.model == CameraModel::Tiny2 + self.model.supports_manual_exposure() } pub fn dump(&self) -> Result<(), Errno> { diff --git a/src/libs/camera/enums.rs b/src/libs/camera/enums.rs index 3ea5c3a..15d9c7b 100644 --- a/src/libs/camera/enums.rs +++ b/src/libs/camera/enums.rs @@ -32,6 +32,31 @@ pub enum CameraModel { } impl CameraModel { + /// Whether this model has the given AI tracking mode. + /// + /// The Tiny 4K only knows a part of the Tiny 2 modes (#72). + pub fn supports_ai_mode(self, mode: AIMode) -> bool { + match self { + CameraModel::Tiny2 => true, + CameraModel::Tiny4K => crate::LegacyAiModeCommand::supports(mode), + } + } + + /// Whether this model has a tracking speed setting. + /// + /// The Tiny 4K has none: the setting does not exist in its protocol. + pub fn supports_tracking_speed(self) -> bool { + self == CameraModel::Tiny2 + } + + /// Whether this model takes the manual exposure mode over this interface. + /// + /// The Tiny 4K drives manual exposure through the standard UVC camera terminal + /// instead, which is reachable via V4L2. + pub fn supports_manual_exposure(self) -> bool { + self == CameraModel::Tiny2 + } + /// Derives the model from the V4L2 card name hint the camera was opened with. pub fn from_hint(hint: &str) -> Self { if hint.contains("Tiny 4K") { From 57991071bef5763fe9f350e73b84770280eb3dae Mon Sep 17 00:00:00 2001 From: hutzelmann <18736645+hutzelmann@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:18:11 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=E2=9C=A8=20#72:=20Report=20the=20real=20tr?= =?UTF-8?q?acking=20mode=20of=20an=20OBSBOT=20Tiny=204K?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit info showed "Unknown" for the AI mode on a Tiny 4K, because that camera leaves the Tiny 2 status bytes at zero. It answers a separate request instead: e3 3092 returns a status block whose second byte says whether the AI engine is enabled and whose byte 33 holds the framing mode, with the same values the tracking mode command takes. Reading it needs care. The camera keeps the previous answer on the command channel until the new one is ready, so a read can return the answer to an earlier request. Every request therefore carries a sequence number that the reply echoes, and the read is retried until the numbers match. That sequence also has to differ between runs: the stale answer outlives the process that asked for it, so a counter starting at the same value every time would accept the previous run's answer. This was not theoretical, it reported a stale mode until fixed. The GUI now also starts with the tracking mode the camera is really in, so the correct button is selected from the first frame. Verified on a Tiny 4K: setting static, normal and upper-body in turn is read back correctly every time, and info still returns in well under a tenth of a second. Co-Authored-By: Claude Opus 5 --- README.md | 22 ++-- src/cli/main.rs | 6 +- src/gui/main.rs | 9 +- src/libs/camera/camera.rs | 53 ++++++++- src/libs/camera/command_legacy.rs | 13 ++- src/libs/camera/commands/ai_mode_legacy.rs | 123 +++++++++++++++++++++ src/libs/camera/mod.rs | 2 +- src/libs/camera/transport.rs | 13 +++ 8 files changed, 225 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f030f31..b976507 100644 --- a/README.md +++ b/README.md @@ -41,17 +41,17 @@ If no Tiny 2 is connected, an **OBSBOT Tiny 4K** is used instead ([#72](https:// The Tiny 4K speaks an older protocol than the Tiny 2, so it understands a part of the commands directly, a part only through its own commands, and some not at all: -| Feature | OBSBOT Tiny 4K | -| ----------------------------------------- | --------------------------------------------------------------------- | -| HDR on/off | ✅ works | -| Exposure mode `face` / `global` | ✅ works | -| Sleep / wake | ✅ works, using the older protocol of the Tiny 4K | -| Tracking `static`, `normal`, `upper-body` | ✅ works, using the older protocol of the Tiny 4K | -| Preset positions | ✅ works, recalls the positions stored by OBSBOT Center | -| Status (`info`) | ⚠️ sleep state and HDR are reported, the tracking mode cannot be read | -| Other tracking modes | ❌ the Tiny 4K does not have them, this is reported | -| Tracking speed | ❌ the Tiny 4K has no such setting, this is reported | -| Exposure mode `manual` | ❌ use the V4L2 exposure controls instead | +| Feature | OBSBOT Tiny 4K | +| ----------------------------------------- | ------------------------------------------------------- | +| HDR on/off | ✅ works | +| Exposure mode `face` / `global` | ✅ works | +| Sleep / wake | ✅ works, using the older protocol of the Tiny 4K | +| Tracking `static`, `normal`, `upper-body` | ✅ works, using the older protocol of the Tiny 4K | +| Preset positions | ✅ works, recalls the positions stored by OBSBOT Center | +| Status (`info`) | ✅ sleep state, tracking mode and HDR are reported | +| Other tracking modes | ❌ the Tiny 4K does not have them, this is reported | +| Tracking speed | ❌ the Tiny 4K has no such setting, this is reported | +| Exposure mode `manual` | ❌ use the V4L2 exposure controls instead | The Tiny 4K ignores settings while it is in standby (about two minutes without a video stream). Start a video stream first, then the settings are applied immediately. diff --git a/src/cli/main.rs b/src/cli/main.rs index 3462b55..f6533e0 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -139,7 +139,11 @@ fn main() { println!("{}:", t!("shared.info.camera_status")); println!(" 💤 {}: {}", t!("shared.info.sleep_mode"), info.awake); - println!(" 🤖 {}: {}", t!("shared.info.ai_mode"), info.ai_mode); + println!( + " 🤖 {}: {}", + t!("shared.info.ai_mode"), + camera.get_ai_mode().unwrap_or(AIMode::Unknown) + ); if camera.supports_tracking_speed() { println!(" 🏃 {}: {}", t!("shared.info.tracking_speed"), info.speed); } else { diff --git a/src/gui/main.rs b/src/gui/main.rs index 0cb7c5c..c6cb36b 100644 --- a/src/gui/main.rs +++ b/src/gui/main.rs @@ -61,13 +61,20 @@ impl MainPanel { .and_then(|c| c.get_status().ok()) .unwrap_or_else(|| tiny4linux::CameraStatus::default()); + // the Tiny 4K keeps its tracking mode outside the status buffer, so it has to + // be asked for separately to start with the right button selected (#72) + let tracking = camera + .as_ref() + .and_then(|c| c.get_ai_mode().ok()) + .unwrap_or(status.ai_mode); + ( MainPanel { camera, main_window_id: None, window_mode, awake: status.awake, - tracking: status.ai_mode, + tracking, tracking_speed: status.speed, hdr_on: status.hdr_on, debugging_on: false, diff --git a/src/libs/camera/camera.rs b/src/libs/camera/camera.rs index 018bf1e..c659d11 100644 --- a/src/libs/camera/camera.rs +++ b/src/libs/camera/camera.rs @@ -10,8 +10,10 @@ use crate::{ SleepCommand, TrackingSpeedCommand, }; use errno::Errno; +use std::sync::atomic::{AtomicU16, Ordering}; use std::thread::sleep; use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; /// Pause between two legacy frames sent in one sequence. /// @@ -20,6 +22,10 @@ use std::time::Duration; /// roughly 95 ms, so the sequence is paced accordingly (#72). const LEGACY_FRAME_GAP: Duration = Duration::from_millis(100); +/// How long to wait before looking for an answer, and how often to look. +const LEGACY_REPLY_GAP: Duration = Duration::from_millis(60); +const LEGACY_REPLY_ATTEMPTS: usize = 6; + /// Device hints tried in this order by [`Camera::detect`]. /// /// A hint has to be part of the V4L2 card name of the camera. @@ -31,6 +37,8 @@ pub struct Camera { transport: CameraTransport, model: CameraModel, debugging: bool, + /// Sequence number for the next legacy request that expects an answer. + sequence_nr: AtomicU16, } impl Camera { @@ -39,6 +47,7 @@ impl Camera { transport: CameraTransport::new(hint)?, model: CameraModel::from_hint(hint), debugging: false, + sequence_nr: AtomicU16::new(Self::first_sequence_nr()), }) } @@ -62,11 +71,46 @@ impl Camera { self.transport.get_status(self.debugging, self.model) } + /// Starting point for the sequence numbers of requests that expect an answer. + /// + /// The camera keeps the previous answer on its command channel, and that answer + /// outlives the process that asked for it. Starting every run at the same number + /// would therefore accept the answer to a *previous* run's request, so the + /// counter starts somewhere that differs between runs (#72). + fn first_sequence_nr() -> u16 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|since_epoch| since_epoch.subsec_nanos() as u16) + .unwrap_or(1) + } + /// The model this camera was detected as, see [`CameraModel`]. pub fn model(&self) -> CameraModel { self.model } + /// Asks an OBSBOT Tiny 4K which tracking mode it is in. + /// + /// The answer is picked up from the command channel, retrying until the camera + /// returns the answer to our own request rather than the previous one (#72). + fn read_ai_mode_tiny_4k(&self) -> Result { + let sequence_nr = self.sequence_nr.fetch_add(1, Ordering::Relaxed); + + self.send_cmd(0x2, 0x2, &LegacyAiModeCommand::status_request(sequence_nr))?; + + for _ in 0..LEGACY_REPLY_ATTEMPTS { + sleep(LEGACY_REPLY_GAP); + + if let Ok(reply) = self.transport.get_reply() + && let Some(mode) = LegacyAiModeCommand::parse_status(&reply, sequence_nr) + { + return Ok(mode); + } + } + + Ok(AIMode::Unknown) + } + /// Whether this camera has the given AI tracking mode. /// /// The Tiny 4K only knows a part of the Tiny 2 modes (#72). @@ -153,8 +197,15 @@ impl Tiny2Camera for Camera { } } + /// Reads the tracking mode the camera is in. + /// + /// The Tiny 2 keeps it in the status buffer. The Tiny 4K leaves those bytes at + /// zero and answers a separate request instead (#72). fn get_ai_mode(&self) -> Result { - Ok(self.get_status()?.ai_mode) + match self.model { + CameraModel::Tiny2 => Ok(self.get_status()?.ai_mode), + CameraModel::Tiny4K => self.read_ai_mode_tiny_4k(), + } } /// Moves the camera to a stored preset position. diff --git a/src/libs/camera/command_legacy.rs b/src/libs/camera/command_legacy.rs index 91f7b7c..d57b299 100644 --- a/src/libs/camera/command_legacy.rs +++ b/src/libs/camera/command_legacy.rs @@ -49,6 +49,17 @@ fn crc16_usb(bytes: &[u8]) -> u16 { /// - `payload`: command arguments, may be empty and at most 48 bytes, since the /// frame is padded to the 60 byte extension unit buffer pub fn command_legacy(route: u8, command: [u8; 2], sequence_nr: u16, payload: &[u8]) -> [u8; 60] { + command_legacy_typed(route, command, sequence_nr, payload, FRAME_TYPE_SET) +} + +/// Builds a legacy frame with an explicit frame type, `0x10` to set and `0x12` to ask. +pub fn command_legacy_typed( + route: u8, + command: [u8; 2], + sequence_nr: u16, + payload: &[u8], + frame_type: u8, +) -> [u8; 60] { debug_assert!( payload.len() <= 60 - FRAME_HEADER_LENGTH as usize, "payload does not fit into the extension unit buffer" @@ -60,7 +71,7 @@ pub fn command_legacy(route: u8, command: [u8; 2], sequence_nr: u16, payload: &[ frame[0] = 0xaa; frame[1] = 0x00; frame[2] = length; - frame[3] = FRAME_TYPE_SET; + frame[3] = frame_type; frame[4..6].copy_from_slice(&sequence_nr.to_be_bytes()); // frame[6..8] stays zeroed while the checksum is calculated over it frame[8] = 0x00; diff --git a/src/libs/camera/commands/ai_mode_legacy.rs b/src/libs/camera/commands/ai_mode_legacy.rs index 6a31452..458c5c9 100644 --- a/src/libs/camera/commands/ai_mode_legacy.rs +++ b/src/libs/camera/commands/ai_mode_legacy.rs @@ -29,9 +29,16 @@ impl LegacyAiModeCommand { /// The `0x3067` payload is a fixed byte, not a boolean. const TARGET_SELECT_PAYLOAD: u8 = 0x00; + const COMMAND_AI_STATUS: [u8; 2] = [0x30, 0x92]; + const FRAME_TYPE_GET: u8 = 0x12; + const TRACKING_MODE_STANDARD: u8 = 0x00; const TRACKING_MODE_HEADROOM: u8 = 0x01; + /// Offsets into the AI status payload. + const STATUS_ENABLED: usize = 1; + const STATUS_TRACKING_MODE: usize = 33; + /// Whether the Tiny 4K offers the given mode at all. /// /// The 4K knows standard, headroom and motion framing. It has none of the @@ -86,6 +93,51 @@ impl LegacyAiModeCommand { } } +impl LegacyAiModeCommand { + /// Builds the request that asks the camera for its AI status. + /// + /// The answer has to be picked up from the command channel afterwards, see + /// [`Self::parse_status`]. + pub fn status_request(sequence_nr: u16) -> [u8; 60] { + crate::libs::camera::command_legacy::command_legacy_typed( + Self::ROUTE_AI, + Self::COMMAND_AI_STATUS, + sequence_nr, + &[], + Self::FRAME_TYPE_GET, + ) + } + + /// Reads the AI status out of a reply, if it is the answer we asked for. + /// + /// The camera keeps the previous answer on the channel until the new one is + /// ready, so the sequence number has to be checked rather than assumed (#72). + pub fn parse_status(reply: &[u8], sequence_nr: u16) -> Option { + let length = *reply.get(2)? as usize; + + if reply.first() != Some(&0xaa) + || reply.len() < length + || length < 12 + || u16::from_be_bytes([*reply.get(4)?, *reply.get(5)?]) != sequence_nr + || reply.get(10..12)? != Self::COMMAND_AI_STATUS + { + return None; + } + + let payload = reply.get(12..length)?; + + Some(match payload.get(Self::STATUS_ENABLED)? { + 0 => AIMode::NoTracking, + _ => match *payload.get(Self::STATUS_TRACKING_MODE)? { + Self::TRACKING_MODE_STANDARD => AIMode::NormalTracking, + Self::TRACKING_MODE_HEADROOM => AIMode::UpperBody, + // the 4K also has a motion mode, which this tool has no name for + _ => AIMode::Unknown, + }, + }) + } +} + #[cfg(test)] mod tests { use crate::{AIMode, LegacyAiModeCommand}; @@ -144,6 +196,77 @@ mod tests { assert!(LegacyAiModeCommand::build(mode).is_err()); } + /// Builds a reply the way the camera does: header, echoed sequence number and + /// command, then the AI status payload. + fn reply(sequence_nr: u16, enabled: u8, tracking_mode: u8) -> [u8; 60] { + let mut reply = [0u8; 60]; + reply[0] = 0xaa; + reply[1] = 0x20; + reply[2] = 0x34; + reply[3] = 0x13; + reply[4..6].copy_from_slice(&sequence_nr.to_be_bytes()); + reply[10..12].copy_from_slice(&[0x30, 0x92]); + reply[12 + 1] = enabled; + reply[12 + 33] = tracking_mode; + reply + } + + #[test_case(0, 0, AIMode::NoTracking; "disabled")] + #[test_case(1, 0, AIMode::NormalTracking; "enabled, standard framing")] + #[test_case(1, 1, AIMode::UpperBody; "enabled, headroom framing")] + #[test_case(1, 2, AIMode::Unknown; "enabled, motion framing has no name here")] + fn reads_the_ai_status(enabled: u8, tracking_mode: u8, expected: AIMode) { + let reply = reply(0x2a, enabled, tracking_mode); + + assert_eq!( + LegacyAiModeCommand::parse_status(&reply, 0x2a), + Some(expected) + ); + } + + #[test] + fn refuses_an_answer_to_a_different_request() { + let reply = reply(0x2a, 1, 0); + + assert_eq!( + LegacyAiModeCommand::parse_status(&reply, 0x2b), + None, + "the camera keeps the previous answer around, so the sequence number decides" + ); + + let mut wrong_command = reply; + wrong_command[10..12].copy_from_slice(&[0x20, 0x01]); + assert_eq!( + LegacyAiModeCommand::parse_status(&wrong_command, 0x2a), + None, + "an answer to another command is not an AI status" + ); + } + + #[test] + fn survives_a_truncated_or_empty_reply() { + assert_eq!(LegacyAiModeCommand::parse_status(&[], 1), None); + assert_eq!(LegacyAiModeCommand::parse_status(&[0xaa, 0x20], 1), None); + assert_eq!( + LegacyAiModeCommand::parse_status(&[0xaa, 0x20, 0x0c, 0x13, 0x00, 0x01], 1), + None, + "a reply without a payload is not a status" + ); + } + + #[test] + fn the_status_request_is_a_get_frame() { + let request = LegacyAiModeCommand::status_request(0x2a); + + assert_eq!(request[3], 0x12, "0x12 asks, 0x10 sets"); + assert_eq!( + request[4..6], + [0x00, 0x2a], + "the sequence number is echoed back" + ); + assert_eq!(request[9..12], [0xe3, 0x30, 0x92]); + } + #[test_case(AIMode::NoTracking; "no tracking")] #[test_case(AIMode::NormalTracking; "normal tracking")] #[test_case(AIMode::UpperBody; "upper body")] diff --git a/src/libs/camera/mod.rs b/src/libs/camera/mod.rs index 5092c5e..7d6a0cd 100644 --- a/src/libs/camera/mod.rs +++ b/src/libs/camera/mod.rs @@ -10,7 +10,7 @@ mod transport; pub use camera::Camera; pub use camera::Tiny2Camera; -pub use command_legacy::command_legacy; +pub use command_legacy::{command_legacy, command_legacy_typed}; pub use command02::command02; pub use commands::*; pub use enums::*; diff --git a/src/libs/camera/transport.rs b/src/libs/camera/transport.rs index 9339083..e620457 100644 --- a/src/libs/camera/transport.rs +++ b/src/libs/camera/transport.rs @@ -151,6 +151,19 @@ impl CameraTransport { Ok(CameraStatus::decode_for(model, &data)) } + /// Reads the answer an OBSBOT Tiny 4K left on its command channel. + /// + /// The camera keeps the previous answer there until the new one is ready, and a + /// read before any request stalls, so callers have to retry and check the + /// sequence number of what they get back (#72). + pub fn get_reply(&self) -> Result<[u8; 60], T4lError> { + let mut data: [u8; 60] = [0u8; 60]; + self.get_cur(0x2, 0x2, &mut data) + .map_err(|x| T4lError::USBIOError(x.0))?; + + Ok(data) + } + /// Reads the preset position table of an OBSBOT Tiny 4K. /// /// The camera returns the whole table in one go on extension unit 2,