From e7c15debe01cc59ed7756fa61e36b5b6ac4c7dee Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 12:59:46 +0100 Subject: [PATCH 1/2] Split camera.rs into the exchange, the Tiny 2 Lite flows and the 0.13 status The exchange with the extension unit (short and framed writes, answer polling, the sequence counter) moves to family/exchange.rs; the Tiny 2 Lite reads and writes stay as models/tiny2lite/camera.rs; the 0.13 status shape, its capability list and the remembered pose move to legacy, with state.rs. Every block is copied unchanged by scripts/groundwork/split-camera.py (anchors matched the file on rebuild as it is); items another file now uses become pub(crate). Co-Authored-By: Claude Sonnet 5.5 --- crates/obsbot-device/src/family/exchange.rs | 80 +++ crates/obsbot-device/src/family/mod.rs | 1 + .../obsbot-device/src/legacy/current_pose.rs | 35 ++ .../obsbot-device/src/legacy/full_status.rs | 418 +++++++++++++++ crates/obsbot-device/src/legacy/mod.rs | 8 + .../obsbot-device/src/{ => legacy}/state.rs | 0 crates/obsbot-device/src/lib.rs | 4 +- .../src/{ => models/tiny2lite}/camera.rs | 504 +----------------- .../obsbot-device/src/models/tiny2lite/mod.rs | 1 + crates/obsbot4linux/src/cli.rs | 3 +- crates/obsbot4linux/src/daemon.rs | 4 +- docs/center-parity.md | 4 +- scripts/groundwork/split-camera.py | 254 +++++++++ 13 files changed, 817 insertions(+), 499 deletions(-) create mode 100644 crates/obsbot-device/src/family/exchange.rs create mode 100644 crates/obsbot-device/src/legacy/current_pose.rs create mode 100644 crates/obsbot-device/src/legacy/full_status.rs create mode 100644 crates/obsbot-device/src/legacy/mod.rs rename crates/obsbot-device/src/{ => legacy}/state.rs (100%) rename crates/obsbot-device/src/{ => models/tiny2lite}/camera.rs (72%) create mode 100644 scripts/groundwork/split-camera.py diff --git a/crates/obsbot-device/src/family/exchange.rs b/crates/obsbot-device/src/family/exchange.rs new file mode 100644 index 0000000..e7cb5ae --- /dev/null +++ b/crates/obsbot-device/src/family/exchange.rs @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5. + +//! The exchange with the camera's extension unit: short commands, framed commands and the +//! polling for their answers, with one sequence number counter for the whole process. + +use crate::error::{Error, Result}; +use crate::family::frame::{Answer, Sequence, parse_answer, query_frame}; +use crate::transport::uvc::Device; +use std::sync::OnceLock; +use std::thread::sleep; +use std::time::Duration; + +const REPLY_GAP: Duration = Duration::from_millis(50); +const REPLY_ATTEMPTS: usize = 20; +/// Gap between the two frames of a pair (preset delete): back to back the camera applies neither. +pub(crate) const PAIR_GAP: Duration = Duration::from_millis(100); + +/// One sequence for the whole process, so frames from separate `Camera` handles (the daemon +/// opens the device per operation) never repeat a number. +fn sequence() -> &'static Sequence { + static SEQ: OnceLock = OnceLock::new(); + SEQ.get_or_init(Sequence::new) +} + +pub struct Camera { + pub(crate) dev: Device, + pub(crate) seq: &'static Sequence, +} + +impl Camera { + pub fn open() -> Result { + Ok(Self { + dev: Device::open()?, + seq: sequence(), + }) + } + + pub(crate) fn short(&self, payload: &[u8]) -> Result<()> { + self.dev.xu_set(6, payload) + } + + pub(crate) fn framed(&self, frame: &[u8]) -> Result<()> { + self.dev.xu_set(2, frame) + } + + pub(crate) fn pair(&self, frames: [[u8; 60]; 2]) -> Result<()> { + self.framed(&frames[0])?; + sleep(PAIR_GAP); + self.framed(&frames[1]) + } + + /// Sends a query and polls selector 2 for the answer with the same sequence number. + pub(crate) fn query(&self, command: u16) -> Result> { + self.query_to(0x04, command) + } + + pub(crate) fn query_to(&self, receiver: u8, command: u16) -> Result> { + let seq = self.seq.next(); + self.framed(&query_frame(receiver, command, seq))?; + self.answer(command, seq) + } + + /// Polls selector 2 for the answer to the query with `seq`. + pub(crate) fn answer(&self, command: u16, seq: u16) -> Result> { + for _ in 0..REPLY_ATTEMPTS { + sleep(REPLY_GAP); + // a read before the answer is ready can fail; just poll again + if let Ok(reply) = self.dev.xu_get(2) { + match parse_answer(&reply, seq, command) { + Some(Answer::Value(data)) => return Ok(data.to_vec()), + Some(Answer::Empty) => return Ok(Vec::new()), + Some(Answer::Rejected(code)) => return Err(Error::Rejected(command, code)), + None => {} + } + } + } + Err(Error::NoAnswer(command)) + } +} diff --git a/crates/obsbot-device/src/family/mod.rs b/crates/obsbot-device/src/family/mod.rs index b025515..e2398a7 100644 --- a/crates/obsbot-device/src/family/mod.rs +++ b/crates/obsbot-device/src/family/mod.rs @@ -2,5 +2,6 @@ //! Building blocks that are not tied to one camera model: the frame codec and the on-air probe. +pub mod exchange; pub mod frame; pub mod streaming; diff --git a/crates/obsbot-device/src/legacy/current_pose.rs b/crates/obsbot-device/src/legacy/current_pose.rs new file mode 100644 index 0000000..f30cbec --- /dev/null +++ b/crates/obsbot-device/src/legacy/current_pose.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5. + +//! The pose 0.13 saves when none is given: the remembered gimbal pose, else the one V4L2 +//! last set, with the live zoom factor. + +use crate::error::Result; +use crate::family::exchange::Camera; +use crate::legacy::state::State; +use crate::models::tiny2lite::presets::{MAX_ZOOM, Pose}; + +const PAN_ABSOLUTE: u32 = 0x009a_0908; +const TILT_ABSOLUTE: u32 = 0x009a_0909; + +impl Camera { + /// The pose to save: the remembered gimbal pose, else the one V4L2 last set (stale + /// after tracking moved the gimbal), plus the live zoom factor. + pub(crate) fn current_pose(&self) -> Result { + let state = State::load().gimbal; + let v4l2 = |id| self.dev.get_ctrl(id).map(|v| v as f32 / 3600.0); + let pan = match state.and_then(|g| g.pan) { + Some(p) => p, + None => v4l2(PAN_ABSOLUTE)?, + }; + let tilt = match state.and_then(|g| g.tilt) { + Some(t) => t, + None => v4l2(TILT_ABSOLUTE)?, + }; + Ok(Pose { + pan, + tilt, + zoom: self.zoom_factor()?.clamp(1.0, MAX_ZOOM), + }) + } +} diff --git a/crates/obsbot-device/src/legacy/full_status.rs b/crates/obsbot-device/src/legacy/full_status.rs new file mode 100644 index 0000000..4985c47 --- /dev/null +++ b/crates/obsbot-device/src/legacy/full_status.rs @@ -0,0 +1,418 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5. + +//! The status of 0.13 (`status --json` and the daemon's state): the full status, its +//! capability list and limits, and the readbacks the daemon keeps between full reads. + +use crate::error::{Error, Result}; +use crate::family::exchange::Camera; +use crate::family::streaming; +use crate::legacy::state::{Startup, State}; +use crate::models::tiny2lite::camera::{ + Framing, Hand, Identity, ImageControl, LivePose, ZOOM_ABSOLUTE, Zone, +}; +use crate::models::tiny2lite::commands::*; +use crate::models::tiny2lite::presets::{MAX_ZOOM, Preset}; +use crate::models::tiny2lite::status::{Focus, ModelInfo, Status, Target, Tracking}; +use serde::Serialize; +use std::collections::BTreeMap; + +/// Commands obsbot4linux supports, for the `capabilities` list of `status --json`. +pub const CAPABILITIES: &[&str] = &[ + "sleep", + "wake", + "tracking", + "speed", + "hdr", + "exposure", + "zoom", + "gimbal", + "gimbal_speed", + "gimbal_stop", + "zoom_speed", + "speed_mode", + "speed_headroom", + "framing", + "fov", + "led", + "led_blink", + "mirror", + "invert", + "auto_sleep", + "sleep_background", + "sleep_background_mirror", + "audio", + "audio_volume", + "gesture", + "hand", + "zone", + "zone_follow", + "zone_return", + "zone_views", + "hand_return", + "hand_area", + "hand_axes", + "hand_return_after", + "hand_gimbal", + "focus", + "focus_af_mode", + "focus_point", + "wake_zoom", + "wake_mode", + "exposure_values", + "startup", + "startup_ai", + "image", + "preset", + "preset_state", + "factory_reset", + "target", + "live_pose_on_demand", +]; + +#[derive(Debug, Serialize)] +pub struct Zoom { + /// Current zoom factor from query 0x6804 (follows gestures and preset recalls). + pub factor: Option, + /// UVC zoom_absolute, 0-100 = 1x-4x: the value last written over V4L2. It does not follow + /// a field of view change, a gesture or a preset recall; `factor` does. + pub absolute: Option, +} + +#[derive(Debug, Serialize)] +pub struct FullStatus { + /// Human name of the camera model, e.g. "Tiny 2 Lite"; null while no camera is connected. + pub model: Option<&'static str>, + /// V4L2 node the camera streams from (stable by-id path when available); null like `model`. + pub device: Option, + /// Whether an application is streaming from the camera (kernel probe, see `streaming::is_streaming`). + pub streaming: bool, + /// `comm` names of those applications, sorted; empty when none. + pub streaming_apps: Vec, + /// Always true here; the daemon reports false when the camera is gone. + pub available: bool, + /// Firmware version such as "6.2.8.1" (query 0x0408); null when the camera did not answer. + pub firmware: Option, + /// Product and platform codes (query 0x1948); null when the camera did not answer. + pub model_info: Option, + pub status: Status, + /// None when the camera did not answer query 0x0104. + pub tracking: Option, + pub zoom: Zoom, + /// Live gimbal pose from query 0x0043 (gimbal controller), also while AI tracking moves it. + pub pose: Option, + /// Tracking target from query 0x01c4; None while Static or when the camera gave no usable answer. + pub target: Option, + pub image: BTreeMap, + /// Microphone volume 0-100 from query 0x80c2; null while asleep or when the camera did not + /// answer. + pub audio_volume: Option, + /// Autofocus mode, manual position and focus window from query 0x3642; null like + /// `audio_volume`. + pub focus: Option, + pub state: StateView, + /// Zone tracking area, follow and return position read from the camera; None when it did not + /// answer the queries. + pub zone: Option, + /// Hand tracking area and return position read from the camera; None when it did not answer. + pub hand: Option, + /// Tracking speed mode and framing options read from the camera; None when it did not answer. + pub framing: Option, + /// The presets stored in the camera, sorted by slot; null when the camera did not answer. + pub presets: Option>, + pub capabilities: Vec<&'static str>, + /// What `gimbal`, the poses and `zone area` accept, so clients need not hardcode it. + pub limits: Limits, +} + +/// `limits` in the status: [min, max] in obsbot4linux's angles (pan positive = camera left, tilt +/// positive = up) and the zoom factor. +#[derive(Debug, Serialize, PartialEq)] +pub struct Limits { + pub pan: [f32; 2], + pub tilt: [f32; 2], + pub zoom: [f32; 2], +} + +pub const LIMITS: Limits = Limits { + pan: [-GIMBAL_PAN_LIMIT, GIMBAL_PAN_LIMIT], + tilt: [GIMBAL_TILT_MIN, GIMBAL_TILT_MAX], + zoom: [1.0, MAX_ZOOM], +}; + +/// `state` in the status: what obsbot4linux remembers (state file), plus `zone_follow` and +/// `startup`, which are read from the camera (null when it did not answer). Kept under `state` +/// so existing clients keep working. +#[derive(Debug, Serialize)] +pub struct StateView { + #[serde(flatten)] + pub remembered: State, + pub zone_follow: Option, + pub startup: Option, +} + +/// The rarely changing readbacks; the daemon keeps them between full reads. +#[derive(Debug, Clone, PartialEq)] +pub struct ZoneInfo { + pub zone: Option, + pub hand: Option, + pub startup: Option, + pub framing: Option, + pub presets: Option>, +} + +impl Camera { + pub fn zone_info(&self) -> ZoneInfo { + ZoneInfo { + zone: self.zone().ok(), + hand: self.hand().ok(), + startup: self.startup().ok(), + framing: self.framing().ok(), + presets: self.presets().ok(), + } + } + + pub fn full_status(&self) -> Result { + self.full_status_with(None, None) + } + + /// Like [`Camera::full_status`], with the zone readbacks and the identity given instead of + /// asked for again. + pub fn full_status_with( + &self, + known: Option, + identity: Option, + ) -> Result { + let status = self.status()?; + let tracking = match self.tracking() { + Ok(t) => Some(t), + Err(Error::NoAnswer(_) | Error::Rejected(..)) => None, + Err(e) => return Err(e), + }; + let factor = self.zoom_factor().ok(); + let absolute = self.dev.get_ctrl(ZOOM_ABSOLUTE).ok(); + let detected = self.dev.detected(); + let streaming_apps = streaming::apps_using(&detected.device); + // an asleep camera does not answer these; asking would only wait out the polling + let (audio_volume, focus) = if status.asleep { + (None, None) + } else { + (self.audio_volume().ok(), self.focus().ok()) + }; + let target = if status.ai_mode == Some(AiMode::Static) { + None + } else { + self.target().ok().flatten() + }; + let ZoneInfo { + zone, + hand, + startup, + framing, + presets, + } = known.unwrap_or_else(|| self.zone_info()); + let Identity { + firmware, + model_info, + } = identity.unwrap_or_else(|| self.identity()); + Ok(FullStatus { + model: Some(detected.model.name), + device: Some(detected.device.to_string_lossy().into_owned()), + streaming: streaming::is_streaming(&detected.device), + streaming_apps, + available: true, + firmware, + model_info, + status, + tracking, + zoom: Zoom { factor, absolute }, + pose: self.live_pose().ok(), + target, + image: self.image_controls()?, + audio_volume, + focus, + state: StateView { + remembered: State::load(), + zone_follow: zone.as_ref().map(|z| z.follow), + startup, + }, + zone, + hand, + framing, + presets, + capabilities: CAPABILITIES.to_vec(), + limits: LIMITS, + }) + } +} + +#[cfg(test)] +mod full_status_tests { + use crate::models::tiny2lite::camera::{HandArea, Offset}; + use crate::models::tiny2lite::presets::{Pose, ZoneView}; + + #[test] + fn hand_serialises_area_and_a_null_return() { + let mut h = Hand { + area: HandArea { + pan_min: -45.0, + pan_max: 45.0, + tilt_min: -30.0, + tilt_max: 30.0, + }, + return_pose: None, + pan_enabled: Some(true), + tilt_enabled: Some(false), + return_after: Some(2.5), + }; + let v = serde_json::to_value(&h).unwrap(); + assert_eq!(v["area"]["tilt_max"], 30.0); + assert_eq!( + (&v["pan_enabled"], &v["tilt_enabled"], &v["return_after"]), + (&true.into(), &false.into(), &2.5.into()) + ); + assert!(v["return"].is_null(), "no return position set"); + h.return_pose = Some(Pose { + pan: 20.0, + tilt: 5.0, + zoom: 1.0, + }); + assert_eq!( + serde_json::to_value(h).unwrap()["return"], + serde_json::json!({"pan": 20.0, "tilt": 5.0, "zoom": 1.0}) + ); + } + + #[test] + fn zone_serialises_with_return_as_key() { + let z = Zone { + pan_min: -10.0, + pan_max: 10.0, + tilt_min: -5.0, + tilt_max: 5.0, + follow: true, + return_pose: Pose { + pan: 0.0, + tilt: 0.0, + zoom: 1.0, + }, + views: Some(vec![ZoneView { + id: 0, + name: "Bereich1".into(), + pose: Pose { + pan: 0.5, + tilt: 0.0, + zoom: 1.5, + }, + }]), + }; + // the keys of 0.11 keep their place and shape; `views` is the only addition + assert_eq!( + serde_json::to_value(z).unwrap(), + serde_json::json!({"pan_min": -10.0, "pan_max": 10.0, "tilt_min": -5.0, "tilt_max": 5.0, + "follow": true, "return": {"pan": 0.0, "tilt": 0.0, "zoom": 1.0}, + "views": [{"id": 0, "name": "Bereich1", "pose": {"pan": 0.5, "tilt": 0.0, "zoom": 1.5}}]}) + ); + } + + use super::*; + + #[test] + fn model_and_device_are_top_level_keys() { + let full = FullStatus { + model: Some("Tiny 2 Lite"), + device: Some("/dev/v4l/by-id/x-video-index0".into()), + streaming: true, + streaming_apps: vec!["ffmpeg".into()], + available: true, + firmware: Some("6.2.8.1".into()), + model_info: Some(ModelInfo { + product: 8, + platform: 8, + }), + status: Status::decode(&[0; 60]), + tracking: None, + zoom: Zoom { + factor: None, + absolute: None, + }, + pose: None, + target: None, + image: BTreeMap::new(), + audio_volume: Some(10), + focus: None, + state: StateView { + remembered: State::default(), + zone_follow: Some(true), + startup: None, + }, + zone: None, + hand: None, + presets: Some(vec![Preset { + slot: 1, + name: "Desk".into(), + pose: Pose { + pan: 10.0, + tilt: -5.0, + zoom: 2.0, + }, + }]), + framing: Some(Framing { + speed_mode: SpeedMode::VeryFast, + auto_zoom: true, + size: FramingSize::MediumCloseUp, + zoom_speed: AutoZoomSpeed::Fast, + offset: Offset { + x: 0.125, + y: -0.0625, + }, + part_mode: PartMode::HeadRemoval, + }), + capabilities: CAPABILITIES.to_vec(), + limits: LIMITS, + }; + let v = serde_json::to_value(full).unwrap(); + // the widget reads these names; they are the snake_case of the CLI names + assert_eq!( + v["framing"], + serde_json::json!({"speed_mode": "very_fast", "auto_zoom": true, + "size": "medium_close_up", "zoom_speed": "fast", + "offset": {"x": 0.125, "y": -0.0625}, "part_mode": "head_removal"}) + ); + // the widget reads `presets` at the top level; a camera that did not answer gives null + assert_eq!( + v["presets"], + serde_json::json!([{"slot": 1, "name": "Desk", "pose": {"pan": 10.0, "tilt": -5.0, "zoom": 2.0}}]) + ); + assert!(CAPABILITIES.contains(&"preset_state")); + assert_eq!(v["model"], "Tiny 2 Lite"); + assert_eq!(v["device"], "/dev/v4l/by-id/x-video-index0"); + assert_eq!(v["streaming"], true); + assert_eq!(v["streaming_apps"], serde_json::json!(["ffmpeg"])); + assert!(v["target"].is_null()); + assert_eq!(v["firmware"], "6.2.8.1"); + assert_eq!( + v["model_info"], + serde_json::json!({"product": 8, "platform": 8}) + ); + // zone_follow stays under state, next to the remembered values + assert_eq!(v["state"]["zone_follow"], true); + assert!(v["state"]["startup"].is_null() && v["zone"].is_null() && v["hand"].is_null()); + // the widget takes its d-pad and zone slider ranges from here + assert_eq!( + v["limits"], + serde_json::json!({"pan": [-150.0, 150.0], "tilt": [-70.0, 90.0], "zoom": [1.0, 4.0]}) + ); + let t = Target { + detected: true, + coords: [0.25, 0.5, 0.75, 1.0], + }; + assert_eq!( + serde_json::to_value(t).unwrap(), + serde_json::json!({"detected": true, "box": [0.25, 0.5, 0.75, 1.0]}) + ); + // the keys the widget already reads stay where they were + assert_eq!(v["available"], true); + assert!(v["status"].is_object() && v["capabilities"].is_array()); + } +} diff --git a/crates/obsbot-device/src/legacy/mod.rs b/crates/obsbot-device/src/legacy/mod.rs new file mode 100644 index 0000000..16b3990 --- /dev/null +++ b/crates/obsbot-device/src/legacy/mod.rs @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! What 0.13 keeps around the camera and what only its daemon and CLI use. The rebuild does not +//! carry it over (spec section 3.2); the 0.13 daemon and CLI use it until they are replaced. + +pub mod current_pose; +pub mod full_status; +pub mod state; diff --git a/crates/obsbot-device/src/state.rs b/crates/obsbot-device/src/legacy/state.rs similarity index 100% rename from crates/obsbot-device/src/state.rs rename to crates/obsbot-device/src/legacy/state.rs diff --git a/crates/obsbot-device/src/lib.rs b/crates/obsbot-device/src/lib.rs index a28deb9..bd2aa46 100644 --- a/crates/obsbot-device/src/lib.rs +++ b/crates/obsbot-device/src/lib.rs @@ -2,9 +2,9 @@ //! Control library for the OBSBOT Tiny 2 Lite. -pub mod camera; pub mod error; -pub mod state; +pub mod legacy; +pub use legacy::state; pub mod family; pub use family::{frame, streaming}; diff --git a/crates/obsbot-device/src/camera.rs b/crates/obsbot-device/src/models/tiny2lite/camera.rs similarity index 72% rename from crates/obsbot-device/src/camera.rs rename to crates/obsbot-device/src/models/tiny2lite/camera.rs index dd2cb39..c543938 100644 --- a/crates/obsbot-device/src/camera.rs +++ b/crates/obsbot-device/src/models/tiny2lite/camera.rs @@ -3,77 +3,20 @@ //! High-level camera operations (one device open per operation). -use crate::commands::{self as cmd, *}; -use crate::device::{CtrlRange, Device}; use crate::error::{Error, Result}; -use crate::frame::{Answer, Sequence, parse_answer, query_frame}; -use crate::presets::{self, MAX_ZOOM, Pose, Preset, StartupActions, StartupAi, ZoneView}; -use crate::state::{Startup, State}; -use crate::status::{Focus, ModelInfo, Status, Target, Tracking, firmware_version}; -use crate::streaming; +use crate::family::exchange::{Camera, PAIR_GAP}; +use crate::legacy::state::{Startup, State}; +use crate::models::tiny2lite::commands::{self as cmd, *}; +use crate::models::tiny2lite::presets::{ + self, MAX_ZOOM, Pose, Preset, StartupActions, StartupAi, ZoneView, +}; +use crate::models::tiny2lite::status::{ + Focus, ModelInfo, Status, Target, Tracking, firmware_version, +}; +use crate::transport::uvc::CtrlRange; use serde::Serialize; use std::collections::BTreeMap; -use std::sync::OnceLock; use std::thread::sleep; -use std::time::Duration; - -const REPLY_GAP: Duration = Duration::from_millis(50); -const REPLY_ATTEMPTS: usize = 20; -/// Gap between the two frames of a pair (preset delete): back to back the camera applies neither. -const PAIR_GAP: Duration = Duration::from_millis(100); - -/// Commands obsbot4linux supports, for the `capabilities` list of `status --json`. -pub const CAPABILITIES: &[&str] = &[ - "sleep", - "wake", - "tracking", - "speed", - "hdr", - "exposure", - "zoom", - "gimbal", - "gimbal_speed", - "gimbal_stop", - "zoom_speed", - "speed_mode", - "speed_headroom", - "framing", - "fov", - "led", - "led_blink", - "mirror", - "invert", - "auto_sleep", - "sleep_background", - "sleep_background_mirror", - "audio", - "audio_volume", - "gesture", - "hand", - "zone", - "zone_follow", - "zone_return", - "zone_views", - "hand_return", - "hand_area", - "hand_axes", - "hand_return_after", - "hand_gimbal", - "focus", - "focus_af_mode", - "focus_point", - "wake_zoom", - "wake_mode", - "exposure_values", - "startup", - "startup_ai", - "image", - "preset", - "preset_state", - "factory_reset", - "target", - "live_pose_on_demand", -]; /// UVC controls for the picture, by CLI name (V4L2 control ids). pub const IMAGE_CONTROLS: &[(&str, u32)] = &[ @@ -97,9 +40,7 @@ const FOCUS_ABSOLUTE: u32 = 0x009a_090a; /// V4L2 auto_exposure (exposure_auto): 0 automatic, 1 manual. const EXPOSURE_AUTO: u32 = 0x009a_0901; const FOCUS_AUTO: u32 = 0x009a_090c; -const ZOOM_ABSOLUTE: u32 = 0x009a_090d; -const PAN_ABSOLUTE: u32 = 0x009a_0908; -const TILT_ABSOLUTE: u32 = 0x009a_0909; +pub(crate) const ZOOM_ABSOLUTE: u32 = 0x009a_090d; #[derive(Debug, Clone, Serialize)] pub struct ImageControl { @@ -108,15 +49,6 @@ pub struct ImageControl { pub range: CtrlRange, } -#[derive(Debug, Serialize)] -pub struct Zoom { - /// Current zoom factor from query 0x6804 (follows gestures and preset recalls). - pub factor: Option, - /// UVC zoom_absolute, 0-100 = 1x-4x: the value last written over V4L2. It does not follow - /// a field of view change, a gesture or a preset recall; `factor` does. - pub absolute: Option, -} - /// Degrees in obsbot4linux's angles: pan positive = camera left, tilt positive = up. #[derive(Debug, Serialize)] pub struct LivePose { @@ -137,78 +69,6 @@ impl LivePose { } } -#[derive(Debug, Serialize)] -pub struct FullStatus { - /// Human name of the camera model, e.g. "Tiny 2 Lite"; null while no camera is connected. - pub model: Option<&'static str>, - /// V4L2 node the camera streams from (stable by-id path when available); null like `model`. - pub device: Option, - /// Whether an application is streaming from the camera (kernel probe, see `streaming::is_streaming`). - pub streaming: bool, - /// `comm` names of those applications, sorted; empty when none. - pub streaming_apps: Vec, - /// Always true here; the daemon reports false when the camera is gone. - pub available: bool, - /// Firmware version such as "6.2.8.1" (query 0x0408); null when the camera did not answer. - pub firmware: Option, - /// Product and platform codes (query 0x1948); null when the camera did not answer. - pub model_info: Option, - pub status: Status, - /// None when the camera did not answer query 0x0104. - pub tracking: Option, - pub zoom: Zoom, - /// Live gimbal pose from query 0x0043 (gimbal controller), also while AI tracking moves it. - pub pose: Option, - /// Tracking target from query 0x01c4; None while Static or when the camera gave no usable answer. - pub target: Option, - pub image: BTreeMap, - /// Microphone volume 0-100 from query 0x80c2; null while asleep or when the camera did not - /// answer. - pub audio_volume: Option, - /// Autofocus mode, manual position and focus window from query 0x3642; null like - /// `audio_volume`. - pub focus: Option, - pub state: StateView, - /// Zone tracking area, follow and return position read from the camera; None when it did not - /// answer the queries. - pub zone: Option, - /// Hand tracking area and return position read from the camera; None when it did not answer. - pub hand: Option, - /// Tracking speed mode and framing options read from the camera; None when it did not answer. - pub framing: Option, - /// The presets stored in the camera, sorted by slot; null when the camera did not answer. - pub presets: Option>, - pub capabilities: Vec<&'static str>, - /// What `gimbal`, the poses and `zone area` accept, so clients need not hardcode it. - pub limits: Limits, -} - -/// `limits` in the status: [min, max] in obsbot4linux's angles (pan positive = camera left, tilt -/// positive = up) and the zoom factor. -#[derive(Debug, Serialize, PartialEq)] -pub struct Limits { - pub pan: [f32; 2], - pub tilt: [f32; 2], - pub zoom: [f32; 2], -} - -pub const LIMITS: Limits = Limits { - pan: [-GIMBAL_PAN_LIMIT, GIMBAL_PAN_LIMIT], - tilt: [GIMBAL_TILT_MIN, GIMBAL_TILT_MAX], - zoom: [1.0, MAX_ZOOM], -}; - -/// `state` in the status: what obsbot4linux remembers (state file), plus `zone_follow` and -/// `startup`, which are read from the camera (null when it did not answer). Kept under `state` -/// so existing clients keep working. -#[derive(Debug, Serialize)] -pub struct StateView { - #[serde(flatten)] - pub remembered: State, - pub zone_follow: Option, - pub startup: Option, -} - /// Zone tracking settings as the camera reports them, in obsbot4linux's angles (pan positive = /// camera left, tilt positive = up; the same as `zone area` takes). #[derive(Debug, Clone, PartialEq, Serialize)] @@ -313,16 +173,6 @@ pub struct Offset { pub y: f32, } -/// The rarely changing readbacks; the daemon keeps them between full reads. -#[derive(Debug, Clone, PartialEq)] -pub struct ZoneInfo { - pub zone: Option, - pub hand: Option, - pub startup: Option, - pub framing: Option, - pub presets: Option>, -} - /// What does not change while the camera stays connected; the daemon reads it once per connect. #[derive(Debug, Clone, PartialEq)] pub struct Identity { @@ -373,68 +223,7 @@ fn auto_zoom_speed_reading(v: u8) -> Option { } } -/// One sequence for the whole process, so frames from separate `Camera` handles (the daemon -/// opens the device per operation) never repeat a number. -fn sequence() -> &'static Sequence { - static SEQ: OnceLock = OnceLock::new(); - SEQ.get_or_init(Sequence::new) -} - -pub struct Camera { - dev: Device, - seq: &'static Sequence, -} - impl Camera { - pub fn open() -> Result { - Ok(Self { - dev: Device::open()?, - seq: sequence(), - }) - } - - fn short(&self, payload: &[u8]) -> Result<()> { - self.dev.xu_set(6, payload) - } - - fn framed(&self, frame: &[u8]) -> Result<()> { - self.dev.xu_set(2, frame) - } - - fn pair(&self, frames: [[u8; 60]; 2]) -> Result<()> { - self.framed(&frames[0])?; - sleep(PAIR_GAP); - self.framed(&frames[1]) - } - - /// Sends a query and polls selector 2 for the answer with the same sequence number. - fn query(&self, command: u16) -> Result> { - self.query_to(0x04, command) - } - - fn query_to(&self, receiver: u8, command: u16) -> Result> { - let seq = self.seq.next(); - self.framed(&query_frame(receiver, command, seq))?; - self.answer(command, seq) - } - - /// Polls selector 2 for the answer to the query with `seq`. - fn answer(&self, command: u16, seq: u16) -> Result> { - for _ in 0..REPLY_ATTEMPTS { - sleep(REPLY_GAP); - // a read before the answer is ready can fail; just poll again - if let Ok(reply) = self.dev.xu_get(2) { - match parse_answer(&reply, seq, command) { - Some(Answer::Value(data)) => return Ok(data.to_vec()), - Some(Answer::Empty) => return Ok(Vec::new()), - Some(Answer::Rejected(code)) => return Err(Error::Rejected(command, code)), - None => {} - } - } - } - Err(Error::NoAnswer(command)) - } - // --- reading --- pub fn status(&self) -> Result { @@ -623,89 +412,6 @@ impl Camera { }) } - pub fn zone_info(&self) -> ZoneInfo { - ZoneInfo { - zone: self.zone().ok(), - hand: self.hand().ok(), - startup: self.startup().ok(), - framing: self.framing().ok(), - presets: self.presets().ok(), - } - } - - pub fn full_status(&self) -> Result { - self.full_status_with(None, None) - } - - /// Like [`Camera::full_status`], with the zone readbacks and the identity given instead of - /// asked for again. - pub fn full_status_with( - &self, - known: Option, - identity: Option, - ) -> Result { - let status = self.status()?; - let tracking = match self.tracking() { - Ok(t) => Some(t), - Err(Error::NoAnswer(_) | Error::Rejected(..)) => None, - Err(e) => return Err(e), - }; - let factor = self.zoom_factor().ok(); - let absolute = self.dev.get_ctrl(ZOOM_ABSOLUTE).ok(); - let detected = self.dev.detected(); - let streaming_apps = streaming::apps_using(&detected.device); - // an asleep camera does not answer these; asking would only wait out the polling - let (audio_volume, focus) = if status.asleep { - (None, None) - } else { - (self.audio_volume().ok(), self.focus().ok()) - }; - let target = if status.ai_mode == Some(AiMode::Static) { - None - } else { - self.target().ok().flatten() - }; - let ZoneInfo { - zone, - hand, - startup, - framing, - presets, - } = known.unwrap_or_else(|| self.zone_info()); - let Identity { - firmware, - model_info, - } = identity.unwrap_or_else(|| self.identity()); - Ok(FullStatus { - model: Some(detected.model.name), - device: Some(detected.device.to_string_lossy().into_owned()), - streaming: streaming::is_streaming(&detected.device), - streaming_apps, - available: true, - firmware, - model_info, - status, - tracking, - zoom: Zoom { factor, absolute }, - pose: self.live_pose().ok(), - target, - image: self.image_controls()?, - audio_volume, - focus, - state: StateView { - remembered: State::load(), - zone_follow: zone.as_ref().map(|z| z.follow), - startup, - }, - zone, - hand, - framing, - presets, - capabilities: CAPABILITIES.to_vec(), - limits: LIMITS, - }) - } - // --- simple settings --- pub fn sleep(&self, asleep: bool) -> Result<()> { @@ -1225,26 +931,6 @@ impl Camera { Ok(list) } - /// The pose to save: the remembered gimbal pose, else the one V4L2 last set (stale - /// after tracking moved the gimbal), plus the live zoom factor. - fn current_pose(&self) -> Result { - let state = State::load().gimbal; - let v4l2 = |id| self.dev.get_ctrl(id).map(|v| v as f32 / 3600.0); - let pan = match state.and_then(|g| g.pan) { - Some(p) => p, - None => v4l2(PAN_ABSOLUTE)?, - }; - let tilt = match state.and_then(|g| g.tilt) { - Some(t) => t, - None => v4l2(TILT_ABSOLUTE)?, - }; - Ok(Pose { - pan, - tilt, - zoom: self.zoom_factor()?.clamp(1.0, MAX_ZOOM), - }) - } - /// Saves a preset: creates it, or updates the pose of an existing one. New presets get /// "Preset " as name unless one is given. `pose` overrides the current pose. pub fn save_preset(&self, slot: u8, name: Option<&str>, pose: Option) -> Result { @@ -1297,174 +983,6 @@ impl Camera { } } -#[cfg(test)] -mod full_status_tests { - #[test] - fn hand_serialises_area_and_a_null_return() { - let mut h = Hand { - area: HandArea { - pan_min: -45.0, - pan_max: 45.0, - tilt_min: -30.0, - tilt_max: 30.0, - }, - return_pose: None, - pan_enabled: Some(true), - tilt_enabled: Some(false), - return_after: Some(2.5), - }; - let v = serde_json::to_value(&h).unwrap(); - assert_eq!(v["area"]["tilt_max"], 30.0); - assert_eq!( - (&v["pan_enabled"], &v["tilt_enabled"], &v["return_after"]), - (&true.into(), &false.into(), &2.5.into()) - ); - assert!(v["return"].is_null(), "no return position set"); - h.return_pose = Some(Pose { - pan: 20.0, - tilt: 5.0, - zoom: 1.0, - }); - assert_eq!( - serde_json::to_value(h).unwrap()["return"], - serde_json::json!({"pan": 20.0, "tilt": 5.0, "zoom": 1.0}) - ); - } - - #[test] - fn zone_serialises_with_return_as_key() { - let z = Zone { - pan_min: -10.0, - pan_max: 10.0, - tilt_min: -5.0, - tilt_max: 5.0, - follow: true, - return_pose: Pose { - pan: 0.0, - tilt: 0.0, - zoom: 1.0, - }, - views: Some(vec![ZoneView { - id: 0, - name: "Bereich1".into(), - pose: Pose { - pan: 0.5, - tilt: 0.0, - zoom: 1.5, - }, - }]), - }; - // the keys of 0.11 keep their place and shape; `views` is the only addition - assert_eq!( - serde_json::to_value(z).unwrap(), - serde_json::json!({"pan_min": -10.0, "pan_max": 10.0, "tilt_min": -5.0, "tilt_max": 5.0, - "follow": true, "return": {"pan": 0.0, "tilt": 0.0, "zoom": 1.0}, - "views": [{"id": 0, "name": "Bereich1", "pose": {"pan": 0.5, "tilt": 0.0, "zoom": 1.5}}]}) - ); - } - - use super::*; - - #[test] - fn model_and_device_are_top_level_keys() { - let full = FullStatus { - model: Some("Tiny 2 Lite"), - device: Some("/dev/v4l/by-id/x-video-index0".into()), - streaming: true, - streaming_apps: vec!["ffmpeg".into()], - available: true, - firmware: Some("6.2.8.1".into()), - model_info: Some(ModelInfo { - product: 8, - platform: 8, - }), - status: Status::decode(&[0; 60]), - tracking: None, - zoom: Zoom { - factor: None, - absolute: None, - }, - pose: None, - target: None, - image: BTreeMap::new(), - audio_volume: Some(10), - focus: None, - state: StateView { - remembered: State::default(), - zone_follow: Some(true), - startup: None, - }, - zone: None, - hand: None, - presets: Some(vec![Preset { - slot: 1, - name: "Desk".into(), - pose: Pose { - pan: 10.0, - tilt: -5.0, - zoom: 2.0, - }, - }]), - framing: Some(Framing { - speed_mode: SpeedMode::VeryFast, - auto_zoom: true, - size: FramingSize::MediumCloseUp, - zoom_speed: AutoZoomSpeed::Fast, - offset: Offset { - x: 0.125, - y: -0.0625, - }, - part_mode: PartMode::HeadRemoval, - }), - capabilities: CAPABILITIES.to_vec(), - limits: LIMITS, - }; - let v = serde_json::to_value(full).unwrap(); - // the widget reads these names; they are the snake_case of the CLI names - assert_eq!( - v["framing"], - serde_json::json!({"speed_mode": "very_fast", "auto_zoom": true, - "size": "medium_close_up", "zoom_speed": "fast", - "offset": {"x": 0.125, "y": -0.0625}, "part_mode": "head_removal"}) - ); - // the widget reads `presets` at the top level; a camera that did not answer gives null - assert_eq!( - v["presets"], - serde_json::json!([{"slot": 1, "name": "Desk", "pose": {"pan": 10.0, "tilt": -5.0, "zoom": 2.0}}]) - ); - assert!(CAPABILITIES.contains(&"preset_state")); - assert_eq!(v["model"], "Tiny 2 Lite"); - assert_eq!(v["device"], "/dev/v4l/by-id/x-video-index0"); - assert_eq!(v["streaming"], true); - assert_eq!(v["streaming_apps"], serde_json::json!(["ffmpeg"])); - assert!(v["target"].is_null()); - assert_eq!(v["firmware"], "6.2.8.1"); - assert_eq!( - v["model_info"], - serde_json::json!({"product": 8, "platform": 8}) - ); - // zone_follow stays under state, next to the remembered values - assert_eq!(v["state"]["zone_follow"], true); - assert!(v["state"]["startup"].is_null() && v["zone"].is_null() && v["hand"].is_null()); - // the widget takes its d-pad and zone slider ranges from here - assert_eq!( - v["limits"], - serde_json::json!({"pan": [-150.0, 150.0], "tilt": [-70.0, 90.0], "zoom": [1.0, 4.0]}) - ); - let t = Target { - detected: true, - coords: [0.25, 0.5, 0.75, 1.0], - }; - assert_eq!( - serde_json::to_value(t).unwrap(), - serde_json::json!({"detected": true, "box": [0.25, 0.5, 0.75, 1.0]}) - ); - // the keys the widget already reads stay where they were - assert_eq!(v["available"], true); - assert!(v["status"].is_object() && v["capabilities"].is_array()); - } -} - #[cfg(test)] mod framing_tests { use super::auto_zoom_speed_reading; diff --git a/crates/obsbot-device/src/models/tiny2lite/mod.rs b/crates/obsbot-device/src/models/tiny2lite/mod.rs index fcf999b..56f189e 100644 --- a/crates/obsbot-device/src/models/tiny2lite/mod.rs +++ b/crates/obsbot-device/src/models/tiny2lite/mod.rs @@ -2,6 +2,7 @@ //! The OBSBOT Tiny 2 Lite (USB 3564:fef9). +pub mod camera; pub mod commands; pub mod presets; pub mod status; diff --git a/crates/obsbot4linux/src/cli.rs b/crates/obsbot4linux/src/cli.rs index e2813d4..501ce83 100644 --- a/crates/obsbot4linux/src/cli.rs +++ b/crates/obsbot4linux/src/cli.rs @@ -5,10 +5,11 @@ use std::io::Write; use clap::{Parser, Subcommand, ValueEnum}; -use obsbot_device::camera::{Camera, Identity}; use obsbot_device::commands::*; use obsbot_device::device::Detected; use obsbot_device::error::{Error, Result}; +use obsbot_device::family::exchange::Camera; +use obsbot_device::models::tiny2lite::camera::Identity; use obsbot_device::presets::{Pose, StartupAi}; use serde_json::{Value, json}; diff --git a/crates/obsbot4linux/src/daemon.rs b/crates/obsbot4linux/src/daemon.rs index b380563..14c4289 100644 --- a/crates/obsbot4linux/src/daemon.rs +++ b/crates/obsbot4linux/src/daemon.rs @@ -10,8 +10,10 @@ use clap::Parser; use nix::errno::Errno; use nix::fcntl::{Flock, FlockArg}; use nix::sys::stat::{Mode, umask}; -use obsbot_device::camera::{CAPABILITIES, Camera, Identity, ZoneInfo}; use obsbot_device::error::{Error, Result}; +use obsbot_device::family::exchange::Camera; +use obsbot_device::legacy::full_status::{CAPABILITIES, ZoneInfo}; +use obsbot_device::models::tiny2lite::camera::Identity; use obsbot_device::state; use obsbot_device::streaming; use serde_json::{Value, json}; diff --git a/docs/center-parity.md b/docs/center-parity.md index 4f660c1..fa628ce 100644 --- a/docs/center-parity.md +++ b/docs/center-parity.md @@ -15,7 +15,7 @@ obsbot4linux does today. It is for discussion, not a plan: nothing here is sched - **CN**: the capture log, which records which Center settings were clicked per capture. It is the evidence for "in Center UI". - **OB**: obsbot4linux 0.13: README, CHANGELOG, `obsbot4linux --help` and the subcommand help, - `CAPABILITIES` in `src/camera.rs`. + `CAPABILITIES` in `crates/obsbot-device/src/legacy/full_status.rs`. Columns: @@ -169,7 +169,7 @@ Rows marked "UVC" are plain V4L2 controls on the camera terminal / processing un ## Fine-grained capabilities -Today `CAPABILITIES` in `src/camera.rs` is one static list that every detected camera reports, +Today `CAPABILITIES` in `crates/obsbot-device/src/legacy/full_status.rs` is one static list that every detected camera reports, whatever it can do. A per-element UI (hide a control the camera lacks) would need names at the granularity of the controls, not of the command groups. Names a per-element UI would need; the ones marked "exists" are in the list already, the others are not: diff --git a/scripts/groundwork/split-camera.py b/scripts/groundwork/split-camera.py new file mode 100644 index 0000000..2cac9db --- /dev/null +++ b/scripts/groundwork/split-camera.py @@ -0,0 +1,254 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Splits crates/obsbot-device/src/models/tiny2lite/camera.rs (the 0.13 camera.rs, moved there +with git mv) into four files. Every block is copied unchanged; the only edits are the visibility +widenings listed in VIS. Run from the repository root: +python3 -I scripts/groundwork/split-camera.py""" + +import os + +D = "crates/obsbot-device/src" +SRC = os.path.join(D, "models/tiny2lite/camera.rs") +L = open(SRC).read().split("\n") + +# (line, text the line starts with): the boundaries of every block below, in camera.rs as on +# main 66287b5. If one does not match, main changed camera.rs: find the anchors with grep -n, +# fix the numbers here and in BLOCKS, and say so in the pull request. +ANCHORS = { + 20: "const REPLY_GAP", + 23: "const PAIR_GAP", + 25: "/// Commands obsbot4linux supports", + 76: "];", + 78: "/// UVC controls", + 100: "const ZOOM_ABSOLUTE", + 101: "const PAN_ABSOLUTE", + 102: "const TILT_ABSOLUTE", + 104: "#[derive(Debug, Clone, Serialize)]", + 109: "}", + 111: "#[derive(Debug, Serialize)]", + 118: "}", + 120: "/// Degrees", + 138: "}", + 140: "#[derive(Debug, Serialize)]", + 184: "}", + 186: "/// `limits`", + 199: "};", + 201: "/// `state`", + 210: "}", + 212: "/// Zone tracking", + 314: "}", + 316: "/// The rarely", + 324: "}", + 326: "/// What does not", + 331: "}", + 333: "/// The microphone", + 374: "}", + 376: "/// One sequence", + 381: "}", + 383: "pub struct Camera", + 386: "}", + 388: "impl Camera {", + 389: " pub fn open", + 394: " }", + 396: " fn short", + 436: " }", + 438: " // --- reading ---", + 624: " }", + 626: " pub fn zone_info", + 634: " }", + 636: " pub fn full_status", + 638: " }", + 640: " /// Like [`Camera::full_status`]", + 707: " }", + 709: " // --- simple settings ---", + 1226: " }", + 1228: " /// The pose to save", + 1246: " }", + 1248: " /// Saves a preset", + 1297: " }", + 1298: "}", + 1300: "#[cfg(test)]", + 1301: "mod full_status_tests {", + 1466: "}", + 1468: "#[cfg(test)]", + 1665: "}", +} +for n, start in ANCHORS.items(): + assert L[n - 1].startswith(start), f"camera.rs line {n}: {L[n - 1]!r}" +assert len(L) == 1666 and L[1665] == "", "camera.rs must have 1665 lines" + +# The only edits: items another file now uses become pub(crate). +VIS = [ + ("const PAIR_GAP", "pub(crate) const PAIR_GAP"), + (" dev: Device,", " pub(crate) dev: Device,"), + (" seq: &'static Sequence,", " pub(crate) seq: &'static Sequence,"), + (" fn short(", " pub(crate) fn short("), + (" fn framed(", " pub(crate) fn framed("), + (" fn pair(", " pub(crate) fn pair("), + (" fn query(", " pub(crate) fn query("), + (" fn query_to(", " pub(crate) fn query_to("), + (" fn answer(", " pub(crate) fn answer("), + ("const ZOOM_ABSOLUTE", "pub(crate) const ZOOM_ABSOLUTE"), + (" fn current_pose(", " pub(crate) fn current_pose("), +] + + +def R(a, b): + out = [] + for line in L[a - 1 : b]: + for old, new in VIS: + if line.startswith(old): + line = new + line[len(old) :] + out.append(line) + return out + + +SPDX = "// SPDX-License-Identifier: GPL-3.0-or-later" +EUPL = "// Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5." + + +def write(path, lines): + with open(os.path.join(D, path), "w") as f: + f.write("\n".join(lines) + "\n") + + +write( + "family/exchange.rs", + [ + SPDX, + EUPL, + "", + "//! The exchange with the camera's extension unit: short commands, framed commands and the", + "//! polling for their answers, with one sequence number counter for the whole process.", + "", + "use crate::error::{Error, Result};", + "use crate::family::frame::{Answer, Sequence, parse_answer, query_frame};", + "use crate::transport::uvc::Device;", + "use std::sync::OnceLock;", + "use std::thread::sleep;", + "use std::time::Duration;", + "", + ] + + R(20, 23) + + [""] + + R(376, 381) + + [""] + + R(383, 386) + + ["", "impl Camera {"] + + R(389, 394) + + [""] + + R(396, 436) + + ["}"], +) + +write( + "legacy/full_status.rs", + [ + SPDX, + EUPL, + "", + "//! The status of 0.13 (`status --json` and the daemon's state): the full status, its", + "//! capability list and limits, and the readbacks the daemon keeps between full reads.", + "", + "use crate::error::{Error, Result};", + "use crate::family::exchange::Camera;", + "use crate::family::streaming;", + "use crate::legacy::state::{Startup, State};", + "use crate::models::tiny2lite::camera::{", + " Framing, Hand, Identity, ImageControl, LivePose, ZOOM_ABSOLUTE, Zone,", + "};", + "use crate::models::tiny2lite::commands::*;", + "use crate::models::tiny2lite::presets::{MAX_ZOOM, Preset};", + "use crate::models::tiny2lite::status::{Focus, ModelInfo, Status, Target, Tracking};", + "use serde::Serialize;", + "use std::collections::BTreeMap;", + "", + ] + + R(25, 76) + + [""] + + R(111, 118) + + [""] + + R(140, 184) + + [""] + + R(186, 199) + + [""] + + R(201, 210) + + [""] + + R(316, 324) + + ["", "impl Camera {"] + + R(626, 634) + + [""] + + R(636, 638) + + [""] + + R(640, 707) + + ["}", ""] + + R(1300, 1301) + + [ + " use crate::models::tiny2lite::camera::{HandArea, Offset};", + " use crate::models::tiny2lite::presets::{Pose, ZoneView};", + "", + ] + + R(1302, 1466), +) + +write( + "legacy/current_pose.rs", + [ + SPDX, + EUPL, + "", + "//! The pose 0.13 saves when none is given: the remembered gimbal pose, else the one V4L2", + "//! last set, with the live zoom factor.", + "", + "use crate::error::Result;", + "use crate::family::exchange::Camera;", + "use crate::legacy::state::State;", + "use crate::models::tiny2lite::presets::{MAX_ZOOM, Pose};", + "", + ] + + R(101, 102) + + ["", "impl Camera {"] + + R(1228, 1246) + + ["}"], +) + +write( + "models/tiny2lite/camera.rs", + L[0:4] + + [ + "", + "use crate::error::{Error, Result};", + "use crate::family::exchange::{Camera, PAIR_GAP};", + "use crate::legacy::state::{Startup, State};", + "use crate::models::tiny2lite::commands::{self as cmd, *};", + "use crate::models::tiny2lite::presets::{", + " self, MAX_ZOOM, Pose, Preset, StartupActions, StartupAi, ZoneView,", + "};", + "use crate::models::tiny2lite::status::{", + " Focus, ModelInfo, Status, Target, Tracking, firmware_version,", + "};", + "use crate::transport::uvc::CtrlRange;", + "use serde::Serialize;", + "use std::collections::BTreeMap;", + "use std::thread::sleep;", + "", + ] + + R(78, 100) + + [""] + + R(104, 109) + + [""] + + R(120, 138) + + [""] + + R(212, 314) + + [""] + + R(326, 331) + + [""] + + R(333, 374) + + ["", "impl Camera {"] + + R(438, 624) + + [""] + + R(709, 1226) + + [""] + + R(1248, 1297) + + ["}", ""] + + R(1468, 1665), +) From 86e9fc2b21daec3fc3d5f82eee952beb6ed8f785 Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 13:06:10 +0100 Subject: [PATCH 2/2] Use the final module paths and drop the re-exports Every use line and the two module paths in code name the modules where they live now; lib.rs declares only the final modules. No behaviour change. Co-Authored-By: Claude Sonnet 5.5 --- crates/obsbot-device/src/legacy/state.rs | 2 +- crates/obsbot-device/src/lib.rs | 17 +++-------------- .../src/models/tiny2lite/camera.rs | 2 +- .../src/models/tiny2lite/commands.rs | 4 ++-- .../src/models/tiny2lite/presets.rs | 4 ++-- .../src/models/tiny2lite/status.rs | 2 +- crates/obsbot-device/src/registry.rs | 2 +- crates/obsbot-device/tests/byte_guard.rs | 6 +++--- crates/obsbot4linux/src/cli.rs | 8 ++++---- crates/obsbot4linux/src/client.rs | 2 +- crates/obsbot4linux/src/daemon.rs | 4 ++-- 11 files changed, 21 insertions(+), 32 deletions(-) diff --git a/crates/obsbot-device/src/legacy/state.rs b/crates/obsbot-device/src/legacy/state.rs index 6da4406..cc9fda8 100644 --- a/crates/obsbot-device/src/legacy/state.rs +++ b/crates/obsbot-device/src/legacy/state.rs @@ -4,8 +4,8 @@ //! exposure mode. The CLI updates it whenever it sets them, so separate calls stay //! consistent. Location: `$XDG_STATE_HOME/obsbot4linux/state.json` (default `~/.local/state`). -use crate::commands::{AiMode, ExposureMode}; use crate::error::{Error, Result}; +use crate::models::tiny2lite::commands::{AiMode, ExposureMode}; use serde::{Deserialize, Serialize}; use std::fs::File; use std::io::Write; diff --git a/crates/obsbot-device/src/lib.rs b/crates/obsbot-device/src/lib.rs index bd2aa46..9c6244f 100644 --- a/crates/obsbot-device/src/lib.rs +++ b/crates/obsbot-device/src/lib.rs @@ -2,21 +2,10 @@ //! Control library for the OBSBOT Tiny 2 Lite. +pub mod enumeration; pub mod error; -pub mod legacy; -pub use legacy::state; - pub mod family; -pub use family::{frame, streaming}; - -pub mod enumeration; +pub mod legacy; +pub mod models; pub mod registry; pub mod transport; -/// The 0.13 path of the device module, until Task 7 of the groundwork plan removes it. -pub mod device { - pub use crate::enumeration::{Detected, detect}; - pub use crate::transport::uvc::{CtrlRange, Device}; -} - -pub mod models; -pub use models::tiny2lite::{commands, presets, status}; diff --git a/crates/obsbot-device/src/models/tiny2lite/camera.rs b/crates/obsbot-device/src/models/tiny2lite/camera.rs index c543938..29cb4d1 100644 --- a/crates/obsbot-device/src/models/tiny2lite/camera.rs +++ b/crates/obsbot-device/src/models/tiny2lite/camera.rs @@ -986,7 +986,7 @@ impl Camera { #[cfg(test)] mod framing_tests { use super::auto_zoom_speed_reading; - use crate::commands::AutoZoomSpeed; + use crate::models::tiny2lite::commands::AutoZoomSpeed; #[test] fn the_zoom_speed_is_read_as_index_or_as_the_default_speed() { diff --git a/crates/obsbot-device/src/models/tiny2lite/commands.rs b/crates/obsbot-device/src/models/tiny2lite/commands.rs index cd517ca..30be001 100644 --- a/crates/obsbot-device/src/models/tiny2lite/commands.rs +++ b/crates/obsbot-device/src/models/tiny2lite/commands.rs @@ -4,10 +4,10 @@ //! Byte builders for the documented commands, plus the setting enums. //! //! Short commands go to unit 2 selector 6 (` `), framed commands to -//! selector 2 (see [`crate::frame`]). Only ids from the protocol reference are used. +//! selector 2 (see [`crate::family::frame`]). Only ids from the protocol reference are used. use crate::error::{Error, Result}; -use crate::frame::{FRAME_SIZE, frame}; +use crate::family::frame::{FRAME_SIZE, frame}; use clap::ValueEnum; use serde::{Deserialize, Serialize}; diff --git a/crates/obsbot-device/src/models/tiny2lite/presets.rs b/crates/obsbot-device/src/models/tiny2lite/presets.rs index 1e0c030..831d60d 100644 --- a/crates/obsbot-device/src/models/tiny2lite/presets.rs +++ b/crates/obsbot-device/src/models/tiny2lite/presets.rs @@ -7,9 +7,9 @@ //! degrees positive up. The camera's preset format has tilt positive down, so the sign is //! flipped when writing and reading. -use crate::commands::{AiMode, gimbal_angles_ok}; use crate::error::{Error, Result}; -use crate::frame::{FRAME_SIZE, frame, query_frame_with}; +use crate::family::frame::{FRAME_SIZE, frame, query_frame_with}; +use crate::models::tiny2lite::commands::{AiMode, gimbal_angles_ok}; use clap::ValueEnum; use serde::{Deserialize, Serialize}; diff --git a/crates/obsbot-device/src/models/tiny2lite/status.rs b/crates/obsbot-device/src/models/tiny2lite/status.rs index 86c0eed..62146bc 100644 --- a/crates/obsbot-device/src/models/tiny2lite/status.rs +++ b/crates/obsbot-device/src/models/tiny2lite/status.rs @@ -5,7 +5,7 @@ //! //! Offsets are those of the Tiny 2 Lite, see the protocol reference `status-block.md`. -use crate::commands::{ +use crate::models::tiny2lite::commands::{ AiMode, AutofocusMode, HandSide, NoiseReduction, PickupDistance, SleepBackground, TrackingSpeed, }; use serde::Serialize; diff --git a/crates/obsbot-device/src/registry.rs b/crates/obsbot-device/src/registry.rs index d8b19a4..cd4ab66 100644 --- a/crates/obsbot-device/src/registry.rs +++ b/crates/obsbot-device/src/registry.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later //! The cameras obsbot4linux supports and how to recognise them. Matching is pure string -//! work so it can be tested without a camera; `device::detect` feeds it from `/dev`. +//! work so it can be tested without a camera; `enumeration::detect` feeds it from `/dev`. /// A supported camera model. #[derive(Debug, PartialEq, Eq)] diff --git a/crates/obsbot-device/tests/byte_guard.rs b/crates/obsbot-device/tests/byte_guard.rs index 3eb07c1..6eb29e4 100644 --- a/crates/obsbot-device/tests/byte_guard.rs +++ b/crates/obsbot-device/tests/byte_guard.rs @@ -10,9 +10,9 @@ //! lists the changed lines). A move never writes it; every other run compares. use clap::ValueEnum; -use obsbot_device::commands::{self as cmd, *}; -use obsbot_device::frame::{self, FRAME_SIZE}; -use obsbot_device::presets::{self, Pose, StartupAi}; +use obsbot_device::family::frame::{self, FRAME_SIZE}; +use obsbot_device::models::tiny2lite::commands::{self as cmd, *}; +use obsbot_device::models::tiny2lite::presets::{self, Pose, StartupAi}; use std::fmt::Display; const FIXTURE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/byte-guard.txt"); diff --git a/crates/obsbot4linux/src/cli.rs b/crates/obsbot4linux/src/cli.rs index 501ce83..4d458e1 100644 --- a/crates/obsbot4linux/src/cli.rs +++ b/crates/obsbot4linux/src/cli.rs @@ -5,12 +5,12 @@ use std::io::Write; use clap::{Parser, Subcommand, ValueEnum}; -use obsbot_device::commands::*; -use obsbot_device::device::Detected; +use obsbot_device::enumeration::Detected; use obsbot_device::error::{Error, Result}; use obsbot_device::family::exchange::Camera; use obsbot_device::models::tiny2lite::camera::Identity; -use obsbot_device::presets::{Pose, StartupAi}; +use obsbot_device::models::tiny2lite::commands::*; +use obsbot_device::models::tiny2lite::presets::{Pose, StartupAi}; use serde_json::{Value, json}; #[derive(Parser)] @@ -972,7 +972,7 @@ pub fn run() -> i32 { /// `obsbot4linux detect`: exit 0 and the model (and device) of a supported camera, exit 1 and /// no output without one. Never goes through the daemon. fn detect(json: bool) -> i32 { - let Some(found) = obsbot_device::device::detect() else { + let Some(found) = obsbot_device::enumeration::detect() else { return 1; }; let (value, text) = detect_output(&found); diff --git a/crates/obsbot4linux/src/client.rs b/crates/obsbot4linux/src/client.rs index 5705b96..2096c31 100644 --- a/crates/obsbot4linux/src/client.rs +++ b/crates/obsbot4linux/src/client.rs @@ -55,7 +55,7 @@ fn connect_or_start() -> Result { } let exe = std::env::current_exe()?; // its errors go next to the state file; without a log they are lost, not fatal - let log = obsbot_device::state::path().with_file_name("daemon.log"); + let log = obsbot_device::legacy::state::path().with_file_name("daemon.log"); let stderr = log .parent() .map_or(Ok(()), std::fs::create_dir_all) diff --git a/crates/obsbot4linux/src/daemon.rs b/crates/obsbot4linux/src/daemon.rs index 14c4289..04372b6 100644 --- a/crates/obsbot4linux/src/daemon.rs +++ b/crates/obsbot4linux/src/daemon.rs @@ -12,10 +12,10 @@ use nix::fcntl::{Flock, FlockArg}; use nix::sys::stat::{Mode, umask}; use obsbot_device::error::{Error, Result}; use obsbot_device::family::exchange::Camera; +use obsbot_device::family::streaming; use obsbot_device::legacy::full_status::{CAPABILITIES, ZoneInfo}; +use obsbot_device::legacy::state; use obsbot_device::models::tiny2lite::camera::Identity; -use obsbot_device::state; -use obsbot_device::streaming; use serde_json::{Value, json}; use std::collections::HashSet; use std::fs::{File, OpenOptions};