diff --git a/README.md b/README.md index 889391f..b976507 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,28 @@ 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 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, 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. + ### 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..f6533e0 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")); @@ -139,8 +139,20 @@ 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); + 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 { + println!( + " 🏃 {}: {}", + t!("shared.info.tracking_speed"), + t!("shared.info.not_available") + ); + } println!(" 💐 {}: {}", t!("shared.info.hdr"), info.hdr_on); } } @@ -216,6 +228,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 +263,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 +393,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")); @@ -398,9 +464,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) { @@ -447,6 +516,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 18a7594..c6cb36b 100644 --- a/src/gui/main.rs +++ b/src/gui/main.rs @@ -54,20 +54,27 @@ 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() .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, @@ -80,7 +87,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(); @@ -127,11 +134,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() @@ -140,7 +157,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) => { @@ -149,6 +168,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() } @@ -167,21 +191,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(), @@ -293,3 +325,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/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 3e774b5..c659d11 100644 --- a/src/libs/camera/camera.rs +++ b/src/libs/camera/camera.rs @@ -1,28 +1,64 @@ // 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, LegacyAiModeCommand, LegacyPresetPositionCommand, LegacySleepCommand, + 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. +/// +/// 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); + +/// 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. +/// 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, + model: CameraModel, debugging: bool, + /// Sequence number for the next legacy request that expects an answer. + sequence_nr: AtomicU16, } impl Camera { pub fn new(hint: &str) -> Result { Ok(Self { transport: CameraTransport::new(hint)?, + model: CameraModel::from_hint(hint), debugging: false, + sequence_nr: AtomicU16::new(Self::first_sequence_nr()), }) } + /// 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() } @@ -32,7 +68,69 @@ impl Camera { } pub fn get_status(&self) -> Result { - self.transport.get_status(self.debugging) + 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). + pub fn supports_ai_mode(&self, mode: AIMode) -> bool { + 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.supports_tracking_speed() + } + + /// 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.supports_manual_exposure() } pub fn dump(&self) -> Result<(), Errno> { @@ -62,30 +160,85 @@ 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 { 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)?; + 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, 0x6, &cmd) + self.send_cmd(0x2, 0x2, frame)?; + } + + Ok(()) + } + } } + /// 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. + /// + /// 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)?; - self.send_cmd(0x2, 0x2, &cmd) + for (index, frame) in LegacyPresetPositionCommand::build(&preset) + .iter() + .enumerate() + { + if index > 0 { + sleep(LEGACY_FRAME_GAP); + } + + self.send_cmd(0x2, 0x2, frame)?; + } + + Ok(()) + } + } } fn get_tracking_speed(&self) -> Result { diff --git a/src/libs/camera/command_legacy.rs b/src/libs/camera/command_legacy.rs new file mode 100644 index 0000000..d57b299 --- /dev/null +++ b/src/libs/camera/command_legacy.rs @@ -0,0 +1,139 @@ +// 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] { + 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" + ); + + 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; + 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/ai_mode_legacy.rs b/src/libs/camera/commands/ai_mode_legacy.rs new file mode 100644 index 0000000..458c5c9 --- /dev/null +++ b/src/libs/camera/commands/ai_mode_legacy.rs @@ -0,0 +1,277 @@ +// 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 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 + /// 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], + ), + ]) + } +} + +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}; + 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()); + } + + /// 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")] + 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 358824b..fa46a5d 100644 --- a/src/libs/camera/commands/mod.rs +++ b/src/libs/camera/commands/mod.rs @@ -1,17 +1,23 @@ // SPDX-License-Identifier: EUPL-1.2 mod ai_mode; +mod ai_mode_legacy; 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; pub use ai_mode::*; +pub use ai_mode_legacy::*; 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/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..15d9c7b 100644 --- a/src/libs/camera/enums.rs +++ b/src/libs/camera/enums.rs @@ -20,6 +20,53 @@ 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 { + /// 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") { + CameraModel::Tiny4K + } else { + CameraModel::Tiny2 + } + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum AIMode { NoTracking, @@ -94,6 +141,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..7d6a0cd 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, command_legacy_typed}; 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..2ee0720 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,9 +11,26 @@ 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), - ai_mode: Self::decode_ai_mode(bytes), + awake: match model { + CameraModel::Tiny2 => Self::decode_sleep_mode(bytes), + CameraModel::Tiny4K => Self::decode_sleep_mode_tiny_4k(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), } @@ -27,6 +44,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..e620457 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,36 @@ impl CameraTransport { println!("Current state: {:?} {:}", data, hex::encode(&data)); } - Ok(CameraStatus::decode(&data)) + 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, + /// 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. diff --git a/src/locales/de.json b/src/locales/de.json index 23d560b..5e4bdf1 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1,7 +1,13 @@ { "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.", + "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": { @@ -18,7 +24,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..5e0c63b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,7 +1,13 @@ { "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.", + "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": { @@ -18,7 +24,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": {