diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a7ed57..b03219b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,7 @@ jobs: persist-credentials: false - run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --no-self-update - run: cargo test --workspace --locked + - run: scripts/check-cli-deps.sh # Builds and tests with the oldest Rust that Cargo.toml's rust-version claims. msrv: diff --git a/Cargo.lock b/Cargo.lock index 6ec3088..602045a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "heck" version = "0.5.0" @@ -171,22 +177,28 @@ dependencies = [ "clap", "hex", "nix", + "obsbot-proto", "serde", "serde_json", "thiserror", ] [[package]] -name = "obsbot4linux" +name = "obsbot-proto" version = "0.13.2" dependencies = [ - "clap", - "nix", - "obsbot-device", + "schemars", "serde", "serde_json", ] +[[package]] +name = "obsbot4linux" +version = "0.13.2" +dependencies = [ + "obsbot-proto", +] + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -211,6 +223,51 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "serde" version = "1.0.229" @@ -241,6 +298,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.154" diff --git a/Cargo.toml b/Cargo.toml index c853df5..de197df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/obsbot-device", "crates/obsbot4linux"] +members = ["crates/obsbot-device", "crates/obsbot4linux", "crates/obsbot-proto"] [workspace.package] version = "0.13.2" @@ -14,6 +14,8 @@ publish = false [workspace.dependencies] obsbot-device = { path = "crates/obsbot-device" } +obsbot-proto = { path = "crates/obsbot-proto" } +schemars = "1.2" clap = { version = "4", features = ["derive"] } nix = { version = "0.31", features = ["fs", "ioctl"] } serde = { version = "1", features = ["derive"] } diff --git a/crates/obsbot-device/Cargo.toml b/crates/obsbot-device/Cargo.toml index 747c50b..0ff8dad 100644 --- a/crates/obsbot-device/Cargo.toml +++ b/crates/obsbot-device/Cargo.toml @@ -13,6 +13,7 @@ publish.workspace = true [dependencies] clap.workspace = true nix.workspace = true +obsbot-proto.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/obsbot-device/src/contract/bus.rs b/crates/obsbot-device/src/contract/bus.rs new file mode 100644 index 0000000..7eec2c7 --- /dev/null +++ b/crates/obsbot-device/src/contract/bus.rs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! How the daemon finds devices: the registry types (8.1) and `trait Bus`, the enumeration +//! seam. The real bus (track A) scans sysfs once a second and opens the USB transport; the +//! fake bus (`models::fake::FakeBus`) holds fake devices in memory (5.12). The daemon's +//! registry thread calls `scan` and starts a worker with `open` for every supported device. + +use crate::contract::camera::{Camera, Result, Tier}; +use obsbot_proto::{Support, Usb}; + +/// A USB (vendor id, product id) pair. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct UsbId { + pub vendor: u16, + pub product: u16, +} + +impl UsbId { + /// From the four-hex-digit strings of `Usb` (`"3564"`, `"fef9"`). + pub fn from_usb(usb: &Usb) -> Option { + let vendor = u16::from_str_radix(&usb.vendor, 16).ok()?; + let product = u16::from_str_radix(&usb.product, 16).ok()?; + Some(UsbId { vendor, product }) + } +} + +/// Where a model's serial comes from (5.5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IdentitySource { + /// The USB serial string, read from sysfs once at plug-in; no request is sent for it. + UsbSerial, + /// The model's identity query, sent while no valid serial has been answered. + Query, + /// None: the device keeps its `port:` id. + Absent, +} + +/// The registry entry of one model (8.2): data only. The bus that opens a model's devices +/// knows its constructor. +#[derive(Debug)] +pub struct ModelEntry { + /// `tiny2lite`, `fake-a`, … + pub key: &'static str, + /// The display name of 4.2 `name` (`OBSBOT Tiny 2 Lite`). + pub display_name: &'static str, + /// Every USB pair this model answers to. + pub usb: &'static [UsbId], + pub support: Support, + /// Where the serial comes from (5.5). + pub identity: IdentitySource, + /// Whether the model has a firmware query. Without one, `firmware` is never asked for + /// and stays "not reported"; with neither query nor `Query` source no attempt is sent. + pub firmware_query: bool, + /// The tier whose next two reads carry identity attempts 2 and 3 (5.5). + pub identity_tier: Tier, + /// The GUID of the model's extension unit, found in the USB descriptor (8.3); `None` for a + /// model without one. + pub xu_guid: Option<&'static str>, + /// The extension-unit selectors the model uses; every other selector is refused (8.3). + pub selectors: &'static [u8], +} + +/// The vendor table and the registered models (8.1). +#[derive(Debug)] +pub struct Registry { + pub vendors: &'static [u16], + pub models: &'static [ModelEntry], +} + +/// What the registry says about one USB device. +#[derive(Debug, Clone, Copy)] +pub enum Lookup { + /// The vendor id is not in the vendor table: the device is not listed at all. + NotListed, + /// A known vendor id with an unregistered product id: an unsupported entry (5.4). + Unsupported, + Model(&'static ModelEntry), +} + +impl Registry { + /// Matches by (vendor id, product id) only; the product string is never used (5.3). + pub fn lookup(&self, id: UsbId) -> Lookup { + if !self.vendors.contains(&id.vendor) { + return Lookup::NotListed; + } + match self.models.iter().find(|m| m.usb.contains(&id)) { + Some(model) => Lookup::Model(model), + None => Lookup::Unsupported, + } + } + + /// Pairs that belong to more than one model; the registry test requires none (8.1). + pub fn shared_pairs(&self) -> Vec { + let mut all: Vec = self + .models + .iter() + .flat_map(|m| m.usb.iter().copied()) + .collect(); + all.sort(); + let mut shared: Vec = all + .windows(2) + .filter(|w| w[0] == w[1]) + .map(|w| w[0]) + .collect(); + shared.dedup(); + shared + } +} + +/// One USB device with a video node and a vendor id from the vendor table, as one scan saw it. +#[derive(Debug, Clone)] +pub struct Found { + pub usb: Usb, + /// The device number on its bus: with `usb.path` it identifies one enumeration (5.3). + pub devnum: u32, + /// `/dev/videoN` of the node with index 0. + pub video_node: Option, + /// The registered model, or `None` for an unsupported device. + pub model: Option<&'static ModelEntry>, +} + +/// The enumeration seam of the daemon. +pub trait Bus: Send + Sync { + /// One enumeration pass. Sends nothing to any device and opens no video node. + fn scan(&self) -> Vec; + + /// Opens the camera of a supported device (`found.model` is `Some`). Sends nothing. + fn open(&self, found: &Found) -> Result>; +} diff --git a/crates/obsbot-device/src/contract/camera.rs b/crates/obsbot-device/src/contract/camera.rs new file mode 100644 index 0000000..35087f1 --- /dev/null +++ b/crates/obsbot-device/src/contract/camera.rs @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! `trait Camera` (design spec 8.2) and its supporting types: groups and tiers, touched +//! targets, identity, the per-operation context and the send table. The daemon knows only +//! these: no command id, no offset, no selector of any model (section 5). + +use obsbot_proto::validate::Refusal; +use obsbot_proto::{Description, Params, State, Values}; +use serde_json::Value; +use std::fmt; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +/// Why a camera operation failed. The daemon maps every variant to the error code `camera`, +/// except `NotInSendTable`, which is `internal` (8.3). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum CameraError { + /// A query was not answered within `ANSWER_POLL` (5.8). + NoAnswer(String), + /// The camera answered with a refusal. + Rejected(String), + /// The device node is gone (unplug): reported at once, without waiting for a timeout. + Gone, + /// The per-operation lock on the video node was not free within `NODE_LOCK_WAIT`. + Locked, + /// The context's deadline passed or a stop interrupted a group read (`Ctx::check`). + Cut, + /// The transport wrapper refused a frame outside the model's send table (8.3). + NotInSendTable(String), + /// Any other operating-system error. + Io(String), +} + +impl fmt::Display for CameraError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CameraError::NoAnswer(what) => write!(f, "the camera did not answer {what}"), + CameraError::Rejected(what) => write!(f, "the camera refused {what}"), + CameraError::Gone => f.write_str("the camera is gone"), + CameraError::Locked => f.write_str("the video node stayed locked by another process"), + CameraError::Cut => f.write_str("cut short by the request deadline or a stop"), + CameraError::NotInSendTable(what) => { + write!(f, "refused a frame outside the send table: {what}") + } + CameraError::Io(what) => write!(f, "I/O error: {what}"), + } + } +} + +impl std::error::Error for CameraError {} + +pub type Result = std::result::Result; + +/// How often a group is read (5.7). Intervals count from the end of the previous read. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Tier { + Second, + FiveSeconds, + Minute, + /// Read at plug-in and after a request that touches it, never periodically. + Touched, +} + +impl Tier { + /// The period of a periodic tier; `None` for `Touched`. + pub fn period(self) -> Option { + match self { + Tier::Second => Some(Duration::from_secs(1)), + Tier::FiveSeconds => Some(Duration::from_secs(5)), + Tier::Minute => Some(Duration::from_secs(60)), + Tier::Touched => None, + } + } +} + +/// A faster tier a group uses while at least one connection subscribes the topic (5.7). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LiveTier { + pub topic: String, + pub tier: Tier, +} + +/// One group of the poll plan: the unit of reading (5.7). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Group { + pub name: String, + pub tier: Tier, + /// Whether the group can be read while the camera is asleep. + pub asleep: bool, + pub live: Option, + /// The leaf paths one read of this group returns. Every described leaf with + /// `readback: true` is in exactly one group; a `readback: false` leaf is in none. + pub leaves: Vec, +} + +/// What an identity attempt asks for (5.5): only what is still missing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Need { + pub serial: bool, + pub firmware: bool, +} + +/// What an identity attempt got. `serial` is the raw answer: the daemon checks it with +/// `obsbot_proto::is_valid_serial`. Never logged (4.2). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Identity { + pub serial: Option, + pub firmware: Option, +} + +/// What a re-read follows: a written leaf or a finished action. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Target<'a> { + Leaf(&'a str), + Action(&'a str), +} + +/// The bounds of one camera operation. A model calls [`Ctx::check`] before every frame it +/// sends: a request stops at its deadline (4.1 rule 9), a group read stops when a stop action +/// arrives (5.6 "Stops"). Both end as `CameraError::Cut`. +#[derive(Debug, Clone, Default)] +pub struct Ctx { + deadline: Option, + interrupt: Option>, +} + +impl Ctx { + /// Any combination: a request's touched re-read has the request's deadline and is also a + /// group read that a stop cuts. + pub fn new(deadline: Option, interrupt: Option>) -> Ctx { + Ctx { + deadline, + interrupt, + } + } + + /// No deadline, no interrupt (tests, identity attempts). + pub fn none() -> Ctx { + Ctx::default() + } + + /// A request: stops once `deadline` has passed. + pub fn until(deadline: Instant) -> Ctx { + Ctx { + deadline: Some(deadline), + interrupt: None, + } + } + + /// A poll: stops once `flag` is set. + pub fn interruptible(flag: Arc) -> Ctx { + Ctx { + deadline: None, + interrupt: Some(flag), + } + } + + /// `Err(Cut)` when the deadline has passed or the interrupt flag is set. + pub fn check(&self) -> Result<()> { + let late = self.deadline.is_some_and(|d| Instant::now() >= d); + let stopped = self + .interrupt + .as_ref() + .is_some_and(|f| f.load(Ordering::SeqCst)); + if late || stopped { + Err(CameraError::Cut) + } else { + Ok(()) + } + } +} + +/// What an action is to the worker's queue and its continuing-motion record (4.1 rules 2 +/// and 6, 5.6). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Motion { + /// Not a motion action. + None, + /// A motion action that ends by itself (`preset.recall`, `gimbal.move`). It clears the + /// continuing-motion record of its device. + Move, + /// A motion action that goes on until `stop` (`gimbal.turn` → `gimbal.stop`). It replaces + /// the record of its device with the connection that sent it. + Continuing { stop: &'static str }, + /// A stop: taken before every queued request (4.1 rule 2). The stop a record names ends + /// that motion, so it clears the record; another stop leaves it. + Stop, +} + +/// One frame a model may send (8.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Frame { + /// A framed command or query on an extension-unit selector. + Framed { + selector: u8, + receiver: u8, + command: u16, + }, + /// A short command on an extension-unit selector. + Short { selector: u8, command: u8 }, + /// A write of a standard UVC control (V4L2 control id). + Uvc { control: u32 }, +} + +/// One entry of a send table, with its evidence (`"capture 11"`, `"measured 2026-10-31"`, +/// or `"0.13.2 only, U-9"` until camera session CS-1). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SendEntry { + pub frame: Frame, + pub evidence: &'static str, + /// An identity query: the only frames allowed on the identity receiver (8.3). + pub identity: bool, +} + +/// Every frame a model may send. The transport wrapper refuses anything else. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SendTable { + pub entries: Vec, +} + +impl SendTable { + /// The table of a model that sends nothing (the fakes). + pub fn empty() -> SendTable { + SendTable::default() + } + + pub fn allows(&self, frame: &Frame) -> bool { + self.entries.iter().any(|e| e.frame == *frame) + } +} + +/// One camera model behind the daemon (8.2). The daemon calls it from one worker thread per +/// device; nothing else touches the device. Signatures change only by a contract-change PR. +pub trait Camera: Send { + /// Settings (with `settle_ms`), actions and limits. + fn description(&self) -> &Description; + + /// One identity attempt (5.5): sends only the queries `need` asks for. A model whose + /// identity source is the USB serial string reads it from sysfs and sends no request. + /// The daemon builds `need` from the registry entry (`ModelEntry::identity` and + /// `firmware_query`) and calls this only when `need` asks for something. + fn identity(&mut self, need: Need, ctx: &Ctx) -> Result; + + /// The groups, their tiers, whether they are readable asleep, their live topics. + fn poll_plan(&self) -> &[Group]; + + /// Reads one group: every leaf of the group → its value (`null` where nullable and not + /// set, `raw:` where undecodable). An error means the group's leaves go to `unread`. + /// A group whose read depends on a condition (on the Tiny 2 Lite the target, read only + /// while the tracking mode is not `static`, 8.8) is decided by the model from its own + /// last reads: while the condition does not hold, it returns its last values, or `null` + /// for a nullable leaf, without sending a frame. + fn read(&mut self, group: &str, ctx: &Ctx) -> Result; + + /// The safe apply order of the leaves of one patch: a permutation of `leaves` in which + /// every `requires` path comes before the leaf that requires it. + fn order(&self, leaves: &[&str]) -> Vec; + + /// Writes one leaf; the value has passed the validator. + fn apply(&mut self, leaf: &str, value: &Value, ctx: &Ctx) -> Result<()>; + + /// The model's own checks of an action against the published state, before any frame is + /// sent: an empty preset slot, an unknown view id, a list in `unread` (`conflict`), an + /// action whose parameters miss a combination the description cannot express (`invalid`). + /// The daemon calls it only after `obsbot_proto::validate::validate_do` passed, so its + /// codes come after every code of the validator. + fn check( + &self, + action: &str, + params: &Params, + state: &State, + ) -> std::result::Result<(), Refusal>; + + /// Runs one action; its parameters have passed the validator and [`Camera::check`]. + /// `state` is the published state that `check` saw (a used or empty preset slot, the + /// mirror and invert flags). Returns the result the description declares + /// (`view.save` → `{"id": …}`), else `None`. + fn act( + &mut self, + action: &str, + params: &Params, + state: &State, + ctx: &Ctx, + ) -> Result>; + + /// The groups to re-read after a leaf was written or an action ran. For a leaf with a + /// readback this includes the leaf's own group. Beyond that, only the groups whose values + /// the camera itself changes in response: a model sends exactly the frames of the request + /// (design spec 2), so no group is touched by a frame of the model's own. + fn touched(&self, target: Target<'_>) -> Vec; + + /// What `action` is to the queue and to the continuing-motion record (4.1 rules 2 and 6, + /// 5.6). `Motion::None` for every name that is not a motion action or a stop. The daemon + /// may ask once per action of the description when it opens the device. + fn motion(&self, action: &str) -> Motion; + + /// Every frame this model may send (8.3). + fn send_table(&self) -> &SendTable; +} diff --git a/crates/obsbot-device/src/contract/conformance.rs b/crates/obsbot-device/src/contract/conformance.rs new file mode 100644 index 0000000..b4e43a1 --- /dev/null +++ b/crates/obsbot-device/src/contract/conformance.rs @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The trait conformance check. Written in W0 task 7. diff --git a/crates/obsbot-device/src/contract/mod.rs b/crates/obsbot-device/src/contract/mod.rs new file mode 100644 index 0000000..1cecbd2 --- /dev/null +++ b/crates/obsbot-device/src/contract/mod.rs @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The contract of the device crate (design spec 8.2, 10.3 W0): `trait Camera`, +//! `trait Transport`, `trait Bus` and the registry types, plus the trait conformance check. +//! These files change only by a contract-change PR (10.1). + +pub mod bus; +pub mod camera; +pub mod conformance; +pub mod transport; + +pub use bus::{Bus, Found, IdentitySource, Lookup, ModelEntry, Registry, UsbId}; +pub use camera::{ + Camera, CameraError, Ctx, Frame, Group, Identity, LiveTier, Motion, Need, SendEntry, SendTable, + Target, Tier, +}; +pub use transport::{ControlRange, OnAir, Transport}; diff --git a/crates/obsbot-device/src/contract/transport.rs b/crates/obsbot-device/src/contract/transport.rs new file mode 100644 index 0000000..47f8929 --- /dev/null +++ b/crates/obsbot-device/src/contract/transport.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! `trait Transport` (design spec 3.1): extension-unit get, set and length by selector, UVC +//! control ioctls, and the facts read from sysfs. One implementation talks to USB UVC devices +//! (track A); a replay implementation answers from recorded exchanges (track A). A model owns +//! one transport; the daemon never sees it. + +use crate::contract::camera::Result; +use obsbot_proto::Usb; + +/// Range and default of a UVC control, as the camera reports them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ControlRange { + pub min: i32, + pub max: i32, + pub step: i32, + pub default: i32, +} + +/// One sample of the on-air probe (4.3 `on_air`): taken together. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OnAir { + pub active: bool, + /// Names of processes that hold the video node open, best effort. + pub apps: Vec, +} + +pub trait Transport: Send { + /// GET_LEN of an extension-unit selector. + fn xu_len(&mut self, selector: u8) -> Result; + /// GET_CUR of an extension-unit selector into `buf` (its length is the selector's length). + fn xu_get(&mut self, selector: u8, buf: &mut [u8]) -> Result<()>; + /// SET_CUR of an extension-unit selector. The wrapper checks the frame first (8.3). + fn xu_set(&mut self, selector: u8, data: &[u8]) -> Result<()>; + /// VIDIOC_G_CTRL. + fn ctrl_get(&mut self, id: u32) -> Result; + /// VIDIOC_S_CTRL. The wrapper checks the control against the send table first. + fn ctrl_set(&mut self, id: u32, value: i32) -> Result<()>; + /// VIDIOC_QUERYCTRL; `None` when the camera does not have the control. + fn ctrl_range(&mut self, id: u32) -> Result>; + /// The USB facts, read from sysfs now (the audio interface of 4.3 `audio.enabled` comes + /// and goes with the camera's restarts). + fn usb(&mut self) -> Result; + /// The USB serial string from sysfs, for a model whose identity source it is. Never logged. + fn usb_serial(&mut self) -> Result>; + /// One sample of the on-air probe on the video node with index 0. + fn on_air(&mut self) -> Result; +} diff --git a/crates/obsbot-device/src/legacy/full_status.rs b/crates/obsbot-device/src/legacy/full_status.rs deleted file mode 100644 index 94fcd64..0000000 --- a/crates/obsbot-device/src/legacy/full_status.rs +++ /dev/null @@ -1,549 +0,0 @@ -// 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()); - } -} - -/// `full_status_with` on a scripted camera (no device is opened). -#[cfg(test)] -mod scripted_camera_tests { - use super::*; - use crate::family::exchange::Camera; - use crate::family::frame::Sequence; - use crate::scripted::{Answer, Scripted}; - use crate::transport::uvc::Device; - use std::sync::OnceLock; - - /// A camera that is awake, with HDR on and the LED in mode 2; it answers the zoom factor - /// (2.0), the live pose (pan 25, tilt 10), the microphone volume (55) and the absolute zoom - /// control (37). The tracking query gets `tracking`; every other query is rejected. - fn camera(tracking: Answer) -> Camera { - camera_in_mode(tracking, (0, 0), Vec::new()) - } - - /// The same camera in the AI mode of the status bytes 0x18 and 0x1c (0, 0 is Static), with - /// `target` as the answer to the target query when it is not empty. - fn camera_in_mode(tracking: Answer, ai_mode: (u8, u8), target: Vec) -> Camera { - let mut status = [0u8; 60]; - status[0x06] = 1; - status[0x18] = ai_mode.0; - status[0x1c] = ai_mode.1; - status[0x21] = 2; - let mut script = Scripted::new(status); - script.answers.insert(0x0104, tracking); - if !target.is_empty() { - script.answers.insert(0x01c4, Answer::Value(target)); - } - script - .answers - .insert(0x6804, Answer::Value(2.0f32.to_le_bytes().to_vec())); - let mut pose = vec![0u8; 12]; - pose[8..10].copy_from_slice(&(-1000i16).to_le_bytes()); - pose[10..12].copy_from_slice(&2500i16.to_le_bytes()); - script.answers.insert(0x0043, Answer::Value(pose)); - script - .answers - .insert(0x80c2, Answer::Value(55i16.to_le_bytes().to_vec())); - script.ctrls.insert(ZOOM_ABSOLUTE, 37); - static SEQ: OnceLock = OnceLock::new(); - Camera { - dev: Device::scripted(script), - seq: SEQ.get_or_init(Sequence::new), - } - } - - /// The zone readbacks and the identity the daemon keeps, so the test asks only for the rest. - fn known() -> (Option, Option) { - ( - Some(ZoneInfo { - zone: None, - hand: None, - startup: None, - framing: None, - presets: None, - }), - Some(Identity { - firmware: Some("6.2.8.1".into()), - model_info: None, - }), - ) - } - - #[test] - fn a_rejected_tracking_query_gives_tracking_null_and_the_rest_of_the_status() { - // the camera refuses 0x0104: status and the daemon's broadcast must still show - // everything else, with `tracking: null`, not fail as a whole - let (zones, identity) = known(); - let full = camera(Answer::Rejected(-3)) - .full_status_with(zones, identity) - .expect("a rejected tracking query does not fail the read"); - assert!(full.tracking.is_none()); - assert!(full.status.hdr); - assert_eq!(full.status.led, 2); - assert_eq!(full.zoom.factor, Some(2.0)); - assert_eq!(full.zoom.absolute, Some(37)); - let pose = full - .pose - .expect("the live pose is read after the tracking query"); - assert_eq!((pose.pan, pose.tilt), (25.0, 10.0)); - assert_eq!(full.audio_volume, Some(55)); - assert_eq!(full.firmware.as_deref(), Some("6.2.8.1")); - assert_eq!(full.model, Some("Tiny 2 Lite")); - } - - #[test] - fn an_answered_tracking_query_is_in_the_status() { - // the same script with a real answer: tracking is filled, so the test above sees the - // arm and not a camera that never reports tracking - let mut answer = vec![0u8; 12]; - answer[0] = 1; // group - answer[10] = 1; // zone tracking - let (zones, identity) = known(); - let full = camera(Answer::Value(answer)) - .full_status_with(zones, identity) - .unwrap(); - assert!(full.tracking.is_some_and(|t| t.zone_tracking)); - } - - #[test] - fn any_other_tracking_error_fails_the_whole_read() { - // only "no answer" and "rejected" are tolerated; a failed write is a broken camera - let (zones, identity) = known(); - let result = camera(Answer::SendFails).full_status_with(zones, identity); - assert!(matches!(result, Err(Error::Io(_))), "{result:?}"); - } - - #[test] - fn the_target_is_asked_only_while_ai_tracking_is_on() { - // `01 01 01 00 00` and four f32; a camera in Static would answer a single 00, - // so it is not asked at all - let mut answer = vec![1, 1, 1, 1, 0, 0]; - for v in [0.25f32, 0.5, 0.75, 1.0] { - answer.extend(v.to_le_bytes()); - } - let target = |ai_mode| { - let (zones, identity) = known(); - camera_in_mode(Answer::Value(vec![0; 12]), ai_mode, answer.clone()) - .full_status_with(zones, identity) - .unwrap() - .target - }; - assert_eq!(target((0, 0)), None, "Static"); - let followed = target((2, 0)).expect("Normal tracking asks for the target"); - assert!(followed.detected); - assert_eq!(followed.coords, [0.25, 0.5, 0.75, 1.0]); - } -} diff --git a/crates/obsbot-device/src/legacy/mod.rs b/crates/obsbot-device/src/legacy/mod.rs index 16b3990..330bb45 100644 --- a/crates/obsbot-device/src/legacy/mod.rs +++ b/crates/obsbot-device/src/legacy/mod.rs @@ -1,8 +1,7 @@ // 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. +//! What 0.13 keeps around the camera. The rebuild does not carry it over (spec section 3.2); the +//! carried Tiny 2 Lite flows use `state` and `current_pose` until track A rewrites them. pub mod current_pose; -pub mod full_status; pub mod state; diff --git a/crates/obsbot-device/src/lib.rs b/crates/obsbot-device/src/lib.rs index 1421c81..7308557 100644 --- a/crates/obsbot-device/src/lib.rs +++ b/crates/obsbot-device/src/lib.rs @@ -2,6 +2,7 @@ //! Control library for the OBSBOT Tiny 2 Lite. +pub mod contract; pub mod enumeration; pub mod error; pub mod family; diff --git a/crates/obsbot-device/src/scripted.rs b/crates/obsbot-device/src/scripted.rs index b8b2ac7..bcee4c2 100644 --- a/crates/obsbot-device/src/scripted.rs +++ b/crates/obsbot-device/src/scripted.rs @@ -18,8 +18,6 @@ use std::sync::OnceLock; pub(crate) enum Answer { /// Type 0x29 with this data. Value(Vec), - /// Type 0x89 with this error code. - Rejected(i16), /// Sending the query fails (the write to selector 2 returns an error). SendFails, } @@ -63,7 +61,6 @@ impl Scripted { let command = u16::from_le_bytes([last[10], last[11]]); Ok(match self.answers.get(&command) { Some(Answer::Value(data)) => answer(0x29, &last, None, data), - Some(Answer::Rejected(code)) => answer(0x89, &last, Some(*code), &[]), Some(Answer::SendFails) | None => answer(0x89, &last, Some(-1), &[]), }) } diff --git a/crates/obsbot-device/tests/contract.rs b/crates/obsbot-device/tests/contract.rs new file mode 100644 index 0000000..b529406 --- /dev/null +++ b/crates/obsbot-device/tests/contract.rs @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The small rules inside the contract types: registry lookup (8.1), the operation context +//! (4.1 rule 9, 5.6), tiers (5.7) and the send table (8.3). + +use obsbot_device::contract::{ + Ctx, Frame, IdentitySource, Lookup, ModelEntry, Registry, SendEntry, SendTable, Tier, UsbId, +}; +use obsbot_proto::{Support, Usb}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +const A: ModelEntry = ModelEntry { + key: "model-a", + display_name: "Model A", + usb: &[ + UsbId { + vendor: 0x3564, + product: 0x0001, + }, + UsbId { + vendor: 0x3564, + product: 0x0002, + }, + ], + support: Support::Tested, + identity: IdentitySource::UsbSerial, + firmware_query: true, + identity_tier: Tier::FiveSeconds, + xu_guid: Some("00000000-0000-0000-0000-000000000001"), + selectors: &[2, 6], +}; +const B: ModelEntry = ModelEntry { + key: "model-b", + display_name: "Model B", + usb: &[UsbId { + vendor: 0x3564, + product: 0x0002, + }], + support: Support::Untested, + identity: IdentitySource::Absent, + firmware_query: false, + identity_tier: Tier::FiveSeconds, + xu_guid: None, + selectors: &[], +}; +static ONE: Registry = Registry { + vendors: &[0x3564], + models: &[A], +}; +static CLASH: Registry = Registry { + vendors: &[0x3564], + models: &[A, B], +}; + +#[test] +fn lookup_uses_the_vendor_table_and_the_usb_pairs_only() { + assert!( + matches!( + ONE.lookup(UsbId { + vendor: 0x046d, + product: 0x0001 + }), + Lookup::NotListed + ), + "unknown vendor: not listed (5.3)" + ); + assert!( + matches!( + ONE.lookup(UsbId { + vendor: 0x3564, + product: 0x0009 + }), + Lookup::Unsupported + ), + "known vendor, unknown product (5.4)" + ); + assert!( + matches!(ONE.lookup(UsbId { vendor: 0x3564, product: 0x0002 }), Lookup::Model(m) if m.key == "model-a"), + "a model may have several pairs" + ); +} + +#[test] +fn a_pair_of_two_models_is_found() { + assert!(ONE.shared_pairs().is_empty()); + assert_eq!( + CLASH.shared_pairs(), + [UsbId { + vendor: 0x3564, + product: 0x0002 + }] + ); +} + +#[test] +fn usb_ids_come_from_the_hex_strings_of_sysfs() { + let usb = Usb { + vendor: "3564".into(), + product: "fef9".into(), + product_name: None, + bcd_device: "0100".into(), + path: "3-2.1".into(), + serial_present: false, + interfaces: vec![], + extension_units: vec![], + }; + assert_eq!( + UsbId::from_usb(&usb), + Some(UsbId { + vendor: 0x3564, + product: 0xfef9 + }) + ); + assert_eq!( + UsbId::from_usb(&Usb { + vendor: "zz".into(), + ..usb + }), + None + ); +} + +#[test] +fn a_context_cuts_at_its_deadline_or_when_interrupted() { + assert!(Ctx::none().check().is_ok()); + assert!( + Ctx::until(Instant::now() + Duration::from_secs(60)) + .check() + .is_ok() + ); + assert!( + Ctx::until(Instant::now() - Duration::from_millis(1)) + .check() + .is_err(), + "deadline passed" + ); + let flag = Arc::new(AtomicBool::new(false)); + let ctx = Ctx::interruptible(flag.clone()); + assert!(ctx.check().is_ok()); + flag.store(true, Ordering::SeqCst); + assert!(ctx.check().is_err(), "a stop arrived"); + + // A request's touched re-read: under the request's deadline and cut by a stop (5.6). + let flag = Arc::new(AtomicBool::new(false)); + let both = Ctx::new( + Some(Instant::now() + Duration::from_secs(60)), + Some(flag.clone()), + ); + assert!(both.check().is_ok()); + flag.store(true, Ordering::SeqCst); + assert!( + both.check().is_err(), + "the stop cuts it before the deadline" + ); + let late = Ctx::new( + Some(Instant::now() - Duration::from_millis(1)), + Some(Arc::new(AtomicBool::new(false))), + ); + assert!(late.check().is_err(), "the deadline cuts it without a stop"); +} + +#[test] +fn tiers_and_send_tables() { + assert_eq!(Tier::Second.period(), Some(Duration::from_secs(1))); + assert_eq!(Tier::FiveSeconds.period(), Some(Duration::from_secs(5))); + assert_eq!(Tier::Minute.period(), Some(Duration::from_secs(60))); + assert_eq!(Tier::Touched.period(), None, "never periodic"); + let table = SendTable { + entries: vec![SendEntry { + frame: Frame::Short { + selector: 6, + command: 0x1a, + }, + evidence: "capture 07", + identity: false, + }], + }; + assert!(table.allows(&Frame::Short { + selector: 6, + command: 0x1a + })); + assert!(!table.allows(&Frame::Short { + selector: 6, + command: 0x1b + })); + assert!(!SendTable::empty().allows(&Frame::Uvc { + control: 0x0098_0900 + })); +} diff --git a/crates/obsbot-proto/Cargo.toml b/crates/obsbot-proto/Cargo.toml new file mode 100644 index 0000000..4a85c86 --- /dev/null +++ b/crates/obsbot-proto/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "obsbot-proto" +description = "Protocol 2 of obsbot4linux: messages, device descriptions, validator, schema and client" +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/obsbot-proto/src/client.rs b/crates/obsbot-proto/src/client.rs new file mode 100644 index 0000000..eb05312 --- /dev/null +++ b/crates/obsbot-proto/src/client.rs @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The synchronous client (design spec 6.1). Written in track C. diff --git a/crates/obsbot-proto/src/description.rs b/crates/obsbot-proto/src/description.rs new file mode 100644 index 0000000..23fb170 --- /dev/null +++ b/crates/obsbot-proto/src/description.rs @@ -0,0 +1,563 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Device entries and descriptions (4.2): `Device`, `Usb`, `Description`, `Setting`, `Action` +//! and their enums, plus builder methods that models use to write their descriptions. +//! +//! Wire rules: maps are sorted by key; `readonly`, `nullable`, `readback` and `no_effect` are +//! always written; every other optional field is left out when it is empty. Each public enum +//! has a catch-all variant, so a newer daemon's additive values (4.1 rule 7) still parse. + +use crate::values::number; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Number, Value}; +use std::collections::BTreeMap; + +fn yes() -> bool { + true +} + +fn is_false(b: &bool) -> bool { + !*b +} + +/// One entry of the device list (4.2). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Device { + /// `sn:` or `port:` (5.5). + pub id: String, + pub name: String, + /// The model key (`tiny2lite`, `fake-a`, `fake-b`), or `unknown` for an unsupported device. + pub model: String, + pub support: Support, + pub serial: Option, + pub firmware: Option, + /// `/dev/videoN` of the video node with sysfs index 0. + pub video_node: Option, + pub available: bool, + /// Set only on the old `port:` entry during an identity hand-over (5.5). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub replaced_by: Option, + pub usb: Usb, + #[serde(flatten)] + pub description: Description, +} + +/// The USB facts of a device, read from sysfs at enumeration (4.2). Never holds the serial. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct Usb { + /// Four lower-case hex digits (`"3564"`). + pub vendor: String, + pub product: String, + pub product_name: Option, + pub bcd_device: String, + /// The USB device name in sysfs (`"3-2.1"`). + pub path: String, + pub serial_present: bool, + /// Interface classes in interface order, two lower-case hex digits (`"0e"`, `"01"`). + pub interfaces: Vec, + pub extension_units: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ExtensionUnit { + pub unit: u8, + /// Lower-case `8-4-4-4-12` form. + pub guid: String, + pub controls: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum Support { + Tested, + Untested, + Unsupported, + /// A level this crate does not know (a newer daemon). + #[serde(untagged)] + Other(String), +} + +/// What a model offers (4.2): the part of a `Device` that `descriptions/.json` holds. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Description { + /// Leaf path → setting. + pub settings: BTreeMap, + /// Action name → action. + pub actions: BTreeMap, + /// List path → maximum item count, where the camera has one. + pub limits: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum SettingType { + Bool, + Enum, + Number, + /// `{min, max}` + Range, + /// `{pan, tilt, zoom}` + Pose, + /// `{x, y}` + Point, + /// `{left, top, right, bottom}` + Box, + String, + /// A read-only list of objects; `item` describes their fields. + List, + #[serde(untagged)] + Other(String), +} + +impl SettingType { + /// The component names of an object-shaped type, `None` for the other types. + pub fn components(&self) -> Option<&'static [&'static str]> { + match self { + SettingType::Range => Some(&["min", "max"]), + SettingType::Pose => Some(&["pan", "tilt", "zoom"]), + SettingType::Point => Some(&["x", "y"]), + SettingType::Box => Some(&["left", "top", "right", "bottom"]), + _ => None, + } + } +} + +/// The closed unit list of 4.2; absent for a raw camera value. New units are additive. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[non_exhaustive] +pub enum Unit { + #[serde(rename = "deg")] + Deg, + #[serde(rename = "deg/s")] + DegPerSecond, + #[serde(rename = "factor")] + Factor, + #[serde(rename = "us")] + Microseconds, + #[serde(rename = "K")] + Kelvin, + #[serde(rename = "percent")] + Percent, + #[serde(rename = "s")] + Seconds, + #[serde(rename = "fraction")] + Fraction, + #[serde(rename = "fps")] + Fps, + #[serde(untagged)] + Other(String), +} + +/// A bound, step or precision: one number for `number`; for `range`, `pose`, `point` and +/// `box` an object of the same shape as the value, one number per component. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum Bound { + One(Number), + Each(BTreeMap), +} + +impl Bound { + /// The bound of one component (`None` for the whole value of a `number`). + pub fn get(&self, component: Option<&str>) -> Option { + match (self, component) { + (Bound::One(n), _) => n.as_f64(), + (Bound::Each(map), Some(c)) => map.get(c).and_then(Number::as_f64), + (Bound::Each(_), None) => None, + } + } +} + +/// A unit: one for the whole value, or one per component (a `pose` has degrees and a factor). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum UnitSpec { + One(Unit), + Each(BTreeMap), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum Asleep { + Accepted, + Ignored, + Unknown, + #[serde(untagged)] + Other(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum Warning { + Restart, + Destructive, + #[serde(untagged)] + Other(String), +} + +/// A condition on another path, checked against the current state with the patch applied. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Requirement { + pub path: String, + pub equals: Value, +} + +/// One leaf of the state, or one action parameter or result field (4.2). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Setting { + #[serde(rename = "type")] + pub kind: SettingType, + pub label: String, + #[serde(default)] + pub readonly: bool, + #[serde(default)] + pub nullable: bool, + /// `false`: the camera accepts the setting but cannot report it. + #[serde(default = "yes")] + pub readback: bool, + /// Present on every writable leaf; absent on read-only leaves and parameters. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asleep: Option, + #[serde(default)] + pub no_effect: bool, + /// enum: every value a state may show. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub values: Vec, + /// enum: the values a `set` accepts. Present on every writable enum, absent on read-only. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub writable: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub step: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub precision: Option, + /// string: the maximum length in characters. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_len: Option, + /// string: printable ASCII only (0x20 to 0x7e). + #[serde(default, skip_serializing_if = "is_false")] + pub ascii: bool, + /// list: the fields of one item. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub item: Option>, + /// Action parameters only: the parameter may be left out. + #[serde(default, skip_serializing_if = "is_false")] + pub optional: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requires: Vec, + /// The camera's own default as it reports it (UVC). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default: Option, + /// The live topic of a volatile leaf. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub live: Option, + /// The longest time the readback may lag a write of this leaf (4.1 rule 4). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settle_ms: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warning: Vec, +} + +/// One action of a device (4.2). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Action { + pub label: String, + /// Parameter name → setting; empty for an action without parameters. + #[serde(default)] + pub params: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option>, + pub asleep: Asleep, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requires: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warning: Vec, +} + +fn num(x: f64) -> Number { + match number(x) { + Value::Number(n) => n, + _ => panic!("description bound {x} is not a finite number"), + } +} + +fn each(pairs: &[(&str, f64)]) -> Bound { + Bound::Each( + pairs + .iter() + .map(|(k, v)| (k.to_string(), num(*v))) + .collect(), + ) +} + +/// Builders for model descriptions. They never touch the wire format. +impl Setting { + /// A writable setting of `kind` with no constraints. + pub fn new(kind: SettingType, label: &str) -> Setting { + Setting { + kind, + label: label.to_string(), + readonly: false, + nullable: false, + readback: true, + asleep: None, + no_effect: false, + values: Vec::new(), + writable: Vec::new(), + min: None, + max: None, + step: None, + unit: None, + precision: None, + max_len: None, + ascii: false, + item: None, + optional: false, + requires: Vec::new(), + default: None, + live: None, + settle_ms: None, + warning: Vec::new(), + } + } + + pub fn boolean(label: &str) -> Setting { + Setting::new(SettingType::Bool, label) + } + + /// An enum whose values are all writable; narrow with [`Setting::writable_only`]. + pub fn enumeration(label: &str, values: &[&str]) -> Setting { + let mut s = Setting::new(SettingType::Enum, label); + s.values = values.iter().map(|v| v.to_string()).collect(); + s.writable = s.values.clone(); + s + } + + /// A number between `min` and `max`. + pub fn number(label: &str, min: f64, max: f64) -> Setting { + Setting::new(SettingType::Number, label).min(min).max(max) + } + + /// A string of at most `max_len` characters. + pub fn string(label: &str, max_len: u32, ascii: bool) -> Setting { + let mut s = Setting::new(SettingType::String, label); + s.max_len = Some(max_len); + s.ascii = ascii; + s + } + + /// A read-only list whose items have the given fields. + pub fn list(label: &str, item: &[(&str, Setting)]) -> Setting { + let mut s = Setting::new(SettingType::List, label).readonly(); + s.item = Some( + item.iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(), + ); + s + } + + /// An object-shaped setting (`range`, `pose`, `point`, `box`) with per-component bounds. + pub fn composite( + kind: SettingType, + label: &str, + min: &[(&str, f64)], + max: &[(&str, f64)], + ) -> Setting { + let mut s = Setting::new(kind, label); + s.min = Some(each(min)); + s.max = Some(each(max)); + s + } + + /// A `range` whose two ends both lie between `lo` and `hi`. + pub fn range(label: &str, lo: f64, hi: f64) -> Setting { + Setting::composite( + SettingType::Range, + label, + &[("min", lo), ("max", lo)], + &[("min", hi), ("max", hi)], + ) + } + + pub fn min(mut self, x: f64) -> Setting { + self.min = Some(Bound::One(num(x))); + self + } + + pub fn max(mut self, x: f64) -> Setting { + self.max = Some(Bound::One(num(x))); + self + } + + pub fn step(mut self, x: f64) -> Setting { + self.step = Some(Bound::One(num(x))); + self + } + + pub fn precision(mut self, x: f64) -> Setting { + self.precision = Some(Bound::One(num(x))); + self + } + + pub fn precisions(mut self, pairs: &[(&str, f64)]) -> Setting { + self.precision = Some(each(pairs)); + self + } + + pub fn unit(mut self, unit: Unit) -> Setting { + self.unit = Some(UnitSpec::One(unit)); + self + } + + pub fn units(mut self, pairs: &[(&str, Unit)]) -> Setting { + self.unit = Some(UnitSpec::Each( + pairs + .iter() + .map(|(k, u)| (k.to_string(), u.clone())) + .collect(), + )); + self + } + + /// Narrows the writable values of an enum. + pub fn writable_only(mut self, values: &[&str]) -> Setting { + self.writable = values.iter().map(|v| v.to_string()).collect(); + self + } + + /// Read-only: no writable values, no asleep behaviour, no settle bound. + pub fn readonly(mut self) -> Setting { + self.readonly = true; + self.writable.clear(); + self.asleep = None; + self.settle_ms = None; + self + } + + pub fn nullable(mut self) -> Setting { + self.nullable = true; + self + } + + /// `readback: false`: the camera accepts the value but cannot report it; no settle bound. + pub fn no_readback(mut self) -> Setting { + self.readback = false; + self.settle_ms = None; + self + } + + pub fn asleep(mut self, asleep: Asleep) -> Setting { + self.asleep = Some(asleep); + self + } + + pub fn no_effect(mut self) -> Setting { + self.no_effect = true; + self + } + + pub fn optional(mut self) -> Setting { + self.optional = true; + self + } + + pub fn requires(mut self, path: &str, equals: Value) -> Setting { + self.requires.push(Requirement { + path: path.to_string(), + equals, + }); + self + } + + pub fn default_value(mut self, value: Value) -> Setting { + self.default = Some(value); + self + } + + pub fn live(mut self, topic: &str) -> Setting { + self.live = Some(topic.to_string()); + self + } + + pub fn settle_ms(mut self, ms: u32) -> Setting { + self.settle_ms = Some(ms); + self + } + + pub fn warning(mut self, warning: Warning) -> Setting { + self.warning.push(warning); + self + } +} + +impl Action { + pub fn new(label: &str, asleep: Asleep) -> Action { + Action { + label: label.to_string(), + params: BTreeMap::new(), + result: None, + asleep, + requires: Vec::new(), + warning: Vec::new(), + } + } + + pub fn param(mut self, name: &str, setting: Setting) -> Action { + self.params.insert(name.to_string(), setting); + self + } + + pub fn result(mut self, name: &str, setting: Setting) -> Action { + self.result + .get_or_insert_with(BTreeMap::new) + .insert(name.to_string(), setting); + self + } + + pub fn requires(mut self, path: &str, equals: Value) -> Action { + self.requires.push(Requirement { + path: path.to_string(), + equals, + }); + self + } + + pub fn warning(mut self, warning: Warning) -> Action { + self.warning.push(warning); + self + } +} + +impl Description { + pub fn setting(mut self, path: &str, setting: Setting) -> Description { + self.settings.insert(path.to_string(), setting); + self + } + + pub fn action(mut self, name: &str, action: Action) -> Description { + self.actions.insert(name.to_string(), action); + self + } + + pub fn limit(mut self, list: &str, max_items: u32) -> Description { + self.limits.insert(list.to_string(), max_items); + self + } + + /// Whether `path` is a group: a proper prefix (followed by `.`) of a setting path. + pub fn is_group(&self, path: &str) -> bool { + let prefix = format!("{path}."); + self.settings.keys().any(|k| k.starts_with(&prefix)) + } +} diff --git a/crates/obsbot-proto/src/ids.rs b/crates/obsbot-proto/src/ids.rs new file mode 100644 index 0000000..d78c1eb --- /dev/null +++ b/crates/obsbot-proto/src/ids.rs @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Request ids, device ids and the one formatter that shortens serials (4.2, 5.5). + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// The `id` of a request: an integer or a string, echoed verbatim in its reply. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum RequestId { + Int(i64), + Str(String), +} + +impl fmt::Display for RequestId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RequestId::Int(n) => write!(f, "{n}"), + RequestId::Str(s) => write!(f, "{s:?}"), + } + } +} + +/// Whether a serial is valid for an `sn:` id: `[A-Za-z0-9]{8,32}` (5.5). The pattern is part +/// of the id format of protocol 2. +pub fn is_valid_serial(serial: &str) -> bool { + (8..=32).contains(&serial.len()) && serial.bytes().all(|b| b.is_ascii_alphanumeric()) +} + +/// `sn:`; the caller has checked the serial with [`is_valid_serial`]. +pub fn sn_id(serial: &str) -> String { + format!("sn:{serial}") +} + +/// `port:`, for a device without a valid serial. +pub fn port_id(usb_path: &str) -> String { + format!("port:{usb_path}") +} + +/// The id or serial as human output, logs and the journal show it (4.2): an `sn:` id or a bare +/// serial becomes `…` plus its last four characters; a `port:` id is shown as it is. +pub fn short_id(id_or_serial: &str) -> String { + if id_or_serial.starts_with("port:") { + return id_or_serial.to_string(); + } + let serial = id_or_serial.strip_prefix("sn:").unwrap_or(id_or_serial); + let chars: Vec = serial.chars().collect(); + let tail: String = chars[chars.len().saturating_sub(4)..].iter().collect(); + format!("…{tail}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serials_follow_the_id_format_of_protocol_2() { + assert!(is_valid_serial("EXAMPLE0000001")); + assert!(is_valid_serial("ABCD1234")); + assert!(!is_valid_serial("ABC1234"), "7 characters is too short"); + assert!( + !is_valid_serial(&"A".repeat(33)), + "33 characters is too long" + ); + assert!( + !is_valid_serial("EXAMPLE-000001"), + "only letters and digits" + ); + assert!(!is_valid_serial("")); + } + + #[test] + fn human_output_never_shows_more_than_four_serial_characters() { + assert_eq!(short_id("sn:EXAMPLE0000001"), "…0001"); + assert_eq!(short_id("EXAMPLE0000001"), "…0001"); + assert_eq!(short_id("port:3-2.1"), "port:3-2.1"); + assert_eq!(sn_id("EXAMPLE0000001"), "sn:EXAMPLE0000001"); + assert_eq!(port_id("3-2.1"), "port:3-2.1"); + } +} diff --git a/crates/obsbot-proto/src/lib.rs b/crates/obsbot-proto/src/lib.rs new file mode 100644 index 0000000..13adedb --- /dev/null +++ b/crates/obsbot-proto/src/lib.rs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Protocol 2 of obsbot4linux (design spec, section 4): the message envelopes, the error +//! codes, the device descriptions, the validator, path helpers and the JSON schema. The +//! synchronous client is the module `client`. +//! +//! State, patches and action parameters are generic: leaf path → JSON value, checked at run +//! time against the device description. There are no per-setting types or methods. + +pub mod client; +pub mod description; +pub mod ids; +pub mod message; +pub mod schema; +pub mod state; +pub mod validate; +pub mod values; + +pub use description::{ + Action, Asleep, Bound, Description, Device, ExtensionUnit, Requirement, Setting, SettingType, + Support, Unit, UnitSpec, Usb, Warning, +}; +pub use ids::{RequestId, is_valid_serial, port_id, short_id, sn_id}; +pub use message::{ + Empty, ErrorBody, ErrorCode, Event, Hello, HelloOk, OkBody, Reply, ReplyBody, Request, + RequestError, RevOk, RevState, ServerMessage, Verb, +}; +pub use state::{Params, Patch, State, Values}; + +use std::path::PathBuf; +use std::time::Duration; + +/// The protocol number this crate speaks (`hello.protocol`). +pub const PROTOCOL: u32 = 2; +/// Environment variable that overrides the socket path. +pub const SOCKET_ENV: &str = "OBSBOT4LINUX_SOCKET"; +/// File name of the socket in `$XDG_RUNTIME_DIR`. +pub const SOCKET_NAME: &str = "obsbot4linux.sock"; +/// A request line longer than this closes the connection (4.1). +pub const MAX_REQUEST_LINE: usize = 64 * 1024; +/// Every device request is answered within this time of its arrival (4.1 rule 9, 5.8). +pub const REQUEST_DEADLINE: Duration = Duration::from_secs(15); +/// The client library reports a lost daemon after this time without a reply (5.8). +pub const CLIENT_REPLY_TIMEOUT: Duration = Duration::from_secs(30); + +/// The socket path: `$OBSBOT4LINUX_SOCKET` if set and not empty, else +/// `$XDG_RUNTIME_DIR/obsbot4linux.sock`; `None` when neither variable is set. +pub fn socket_path() -> Option { + if let Some(path) = std::env::var_os(SOCKET_ENV).filter(|p| !p.is_empty()) { + return Some(PathBuf::from(path)); + } + std::env::var_os("XDG_RUNTIME_DIR") + .filter(|d| !d.is_empty()) + .map(|dir| PathBuf::from(dir).join(SOCKET_NAME)) +} diff --git a/crates/obsbot-proto/src/message.rs b/crates/obsbot-proto/src/message.rs new file mode 100644 index 0000000..30d8c6c --- /dev/null +++ b/crates/obsbot-proto/src/message.rs @@ -0,0 +1,463 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The envelopes of protocol 2 (4.1): requests, replies, events and errors. One JSON object +//! per line; [`Request::from_line`] is the daemon's parser, `serde_json::to_string` gives the +//! canonical line of every type. + +use crate::description::Device; +use crate::ids::RequestId; +use crate::state::{Params, Patch, State}; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::fmt; + +fn yes() -> bool { + true +} + +/// The `hello` verb. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Hello { + pub protocol: u32, + /// `" "` of the client. + pub client: String, + /// Events for this connection; `true` when left out on the wire. + #[serde(default = "yes")] + pub subscribe: bool, + /// Live topics (4.1 rule 8); empty when left out. + #[serde(default)] + pub live: Vec, +} + +impl Hello { + /// A `hello` for protocol 2 with `subscribe: false` and no live topics. + pub fn new(client: &str) -> Hello { + Hello { + protocol: crate::PROTOCOL, + client: client.to_string(), + subscribe: false, + live: Vec::new(), + } + } + + pub fn subscribe(mut self, on: bool) -> Hello { + self.subscribe = on; + self + } + + pub fn live(mut self, topics: &[&str]) -> Hello { + self.live = topics.iter().map(|t| t.to_string()).collect(); + self + } +} + +/// One request line. Exactly one verb. +#[derive(Debug, Clone, PartialEq)] +pub struct Request { + pub id: RequestId, + pub verb: Verb, +} + +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum Verb { + Hello(Hello), + /// `paths: None` asks for the full state (`"get": null`). + Get { + device: String, + paths: Option>, + }, + Set { + device: String, + patch: Patch, + }, + /// `"do"` with its parameters (`"with"`, `{}` when left out). + Do { + device: String, + action: String, + params: Params, + }, + Live { + topics: Vec, + }, +} + +impl Verb { + /// The device id a device request names; `None` for `hello` and `live`. + pub fn device(&self) -> Option<&str> { + match self { + Verb::Get { device, .. } | Verb::Set { device, .. } | Verb::Do { device, .. } => { + Some(device) + } + Verb::Hello(_) | Verb::Live { .. } => None, + } + } +} + +/// Why a request line was refused before it reached a device: always error code `invalid` +/// (the daemon answers `protocol` instead when this is the first line of a connection). +#[derive(Debug, Clone, PartialEq)] +pub struct RequestError { + /// The id, when the line had a usable one; the reply carries `id: null` otherwise. + pub id: Option, + pub message: String, +} + +impl fmt::Display for RequestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for RequestError {} + +/// The wire form of a request: every verb optional, so that "no verb" and "two verbs" can be +/// told apart from malformed JSON. +#[derive(Serialize, Deserialize, JsonSchema)] +struct WireRequest { + #[serde(default)] + id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + device: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + hello: Option, + #[serde(default, skip_serializing_if = "Option::is_none", with = "nullable")] + #[schemars(with = "Option>")] + get: Option>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + set: Option, + #[serde(default, rename = "do", skip_serializing_if = "Option::is_none")] + act: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + with: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + live: Option>, +} + +/// `Some(None)` for an explicit `null`, `None` for a missing field. +mod nullable { + use super::*; + + pub fn serialize( + v: &Option>>, + s: S, + ) -> Result { + match v { + Some(inner) => inner.serialize(s), + None => s.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + d: D, + ) -> Result>>, D::Error> { + Option::>::deserialize(d).map(Some) + } +} + +impl Request { + /// Parses one request line. Errors carry the id when the line had a usable one. + pub fn from_line(line: &str) -> Result { + let value: Value = serde_json::from_str(line).map_err(|e| RequestError { + id: None, + message: format!("not JSON: {e}"), + })?; + let id = value + .get("id") + .and_then(|v| serde_json::from_value::(v.clone()).ok()); + let wire: WireRequest = serde_json::from_value(value).map_err(|e| RequestError { + id: id.clone(), + message: format!("malformed request: {e}"), + })?; + Request::try_from(wire) + } +} + +impl TryFrom for Request { + type Error = RequestError; + + fn try_from(w: WireRequest) -> Result { + let fail = |id: Option, message: &str| RequestError { + id, + message: message.to_string(), + }; + let verbs = [ + w.hello.is_some(), + w.get.is_some(), + w.set.is_some(), + w.act.is_some(), + w.live.is_some(), + ]; + let id = w.id.clone(); + match verbs.iter().filter(|v| **v).count() { + 0 => return Err(fail(id, "the request has no verb")), + 1 => {} + _ => return Err(fail(id, "the request has more than one verb")), + } + let Some(id) = w.id else { + return Err(fail(None, "the request has no id")); + }; + let device = |d: Option| { + d.ok_or_else(|| fail(Some(id.clone()), "the request names no device")) + }; + let verb = if let Some(hello) = w.hello { + Verb::Hello(hello) + } else if let Some(paths) = w.get { + Verb::Get { + device: device(w.device)?, + paths, + } + } else if let Some(patch) = w.set { + Verb::Set { + device: device(w.device)?, + patch, + } + } else if let Some(action) = w.act { + Verb::Do { + device: device(w.device)?, + action, + params: w.with.unwrap_or_default(), + } + } else { + Verb::Live { + topics: w.live.unwrap_or_default(), + } + }; + Ok(Request { id, verb }) + } +} + +impl From<&Request> for WireRequest { + fn from(r: &Request) -> WireRequest { + let mut w = WireRequest { + id: Some(r.id.clone()), + device: r.verb.device().map(str::to_string), + hello: None, + get: None, + set: None, + act: None, + with: None, + live: None, + }; + match &r.verb { + Verb::Hello(h) => w.hello = Some(h.clone()), + Verb::Get { paths, .. } => w.get = Some(paths.clone()), + Verb::Set { patch, .. } => w.set = Some(patch.clone()), + Verb::Do { action, params, .. } => { + w.act = Some(action.clone()); + w.with = (!params.is_empty()).then(|| params.clone()); + } + Verb::Live { topics } => w.live = Some(topics.clone()), + } + w + } +} + +impl Serialize for Request { + fn serialize(&self, s: S) -> Result { + WireRequest::from(self).serialize(s) + } +} + +impl<'de> Deserialize<'de> for Request { + fn deserialize>(d: D) -> Result { + let wire = WireRequest::deserialize(d)?; + Request::try_from(wire).map_err(serde::de::Error::custom) + } +} + +impl JsonSchema for Request { + fn schema_name() -> std::borrow::Cow<'static, str> { + "Request".into() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let mut schema = WireRequest::json_schema(generator); + schema.insert( + "description".into(), + "One request line: `id` and exactly one verb (`hello`, `get`, `set`, `do`, `live`). \ + `get`, `set` and `do` name a `device`; `with` holds the parameters of `do`." + .into(), + ); + schema + } +} + +/// A reply: `{"id":…,"ok":{…}}` or `{"id":…,"error":{…}}`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Reply { + /// The request's id; `null` only for a `protocol` or `invalid` error without a usable id. + pub id: Option, + #[serde(flatten)] + pub body: ReplyBody, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ReplyBody { + Ok(OkBody), + Error(ErrorBody), +} + +/// The `ok` payload. Its shape follows the verb it answers. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +#[non_exhaustive] +pub enum OkBody { + /// The reply to `hello`. + Hello(HelloOk), + /// The reply to `get`. + State(RevState), + /// The reply to `set` and `do`. + Rev(RevOk), + /// The reply to `live`. + Empty(Empty), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct HelloOk { + pub protocol: u32, + /// The daemon's version. + pub daemon: String, + pub devices: Vec, + /// Device id → its rev and state. + pub states: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RevState { + pub rev: u64, + pub state: State, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RevOk { + pub rev: u64, + /// An action's result, when its description declares one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Empty {} + +/// The `error` payload (4.1, Errors). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ErrorBody { + pub code: ErrorCode, + /// English, for logs. + pub message: String, + /// Present whenever the daemon re-read the device before answering. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rev: Option, + /// `camera`: the leaf paths written before the failure. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub applied: Option>, + /// `camera`: the leaf path or action that failed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failed: Option, + /// `protocol`: the protocol numbers the daemon speaks. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supported: Option>, +} + +impl ErrorBody { + pub fn new(code: ErrorCode, message: &str) -> ErrorBody { + ErrorBody { + code, + message: message.to_string(), + rev: None, + applied: None, + failed: None, + supported: None, + } + } +} + +/// The error codes the daemon sends (4.1). A code this crate does not know parses as +/// `Other`; clients treat it as `internal` (4.1 rule 7). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ErrorCode { + Protocol, + Invalid, + UnknownDevice, + Unsupported, + UnsupportedDevice, + OutOfRange, + Conflict, + Unavailable, + Asleep, + Busy, + Camera, + Internal, + #[serde(untagged)] + Other(String), +} + +impl ErrorCode { + /// Every code the daemon may send, in the order of the table in 4.1. + pub const ALL: [ErrorCode; 12] = [ + ErrorCode::Protocol, + ErrorCode::Invalid, + ErrorCode::UnknownDevice, + ErrorCode::Unsupported, + ErrorCode::UnsupportedDevice, + ErrorCode::OutOfRange, + ErrorCode::Conflict, + ErrorCode::Unavailable, + ErrorCode::Asleep, + ErrorCode::Busy, + ErrorCode::Camera, + ErrorCode::Internal, + ]; + + /// The wire name (`"out_of_range"`). + pub fn as_str(&self) -> &str { + match self { + ErrorCode::Protocol => "protocol", + ErrorCode::Invalid => "invalid", + ErrorCode::UnknownDevice => "unknown_device", + ErrorCode::Unsupported => "unsupported", + ErrorCode::UnsupportedDevice => "unsupported_device", + ErrorCode::OutOfRange => "out_of_range", + ErrorCode::Conflict => "conflict", + ErrorCode::Unavailable => "unavailable", + ErrorCode::Asleep => "asleep", + ErrorCode::Busy => "busy", + ErrorCode::Camera => "camera", + ErrorCode::Internal => "internal", + ErrorCode::Other(code) => code, + } + } +} + +/// An event (only on connections with `subscribe: true`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "event", rename_all = "snake_case")] +#[non_exhaustive] +pub enum Event { + /// The full state of one device. + State { + device: String, + rev: u64, + state: State, + }, + /// The full device list. + Devices { devices: Vec }, + /// An event type this crate does not know; clients ignore it (4.1 rule 7). + #[serde(other, skip_serializing)] + #[schemars(skip)] + Unknown, +} + +/// One line from the daemon: an event (it has `event`) or a reply. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum ServerMessage { + Event(Event), + Reply(Reply), +} diff --git a/crates/obsbot-proto/src/schema.rs b/crates/obsbot-proto/src/schema.rs new file mode 100644 index 0000000..3b01502 --- /dev/null +++ b/crates/obsbot-proto/src/schema.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The JSON schema of protocol 2, `schema/protocol-2.json` (design spec, sections 3 and 4). +//! It covers every line on the socket (requests, replies, events) and, through `Device`, the +//! description format. It is generated from the types of this crate and checked for staleness +//! by `tests/schema.rs`. + +use crate::description::Description; +use crate::message::{Request, ServerMessage}; +use schemars::generate::SchemaSettings; +use serde_json::{Value, json}; + +/// The schema as a JSON value. +pub fn protocol_schema() -> Value { + let mut generator = SchemaSettings::draft2020_12().into_generator(); + let request = generator.subschema_for::(); + let server = generator.subschema_for::(); + generator.subschema_for::(); + let defs = generator.take_definitions(true); + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "obsbot4linux protocol 2", + "description": "One JSON object per line on the socket: a Request from a client, or a ServerMessage (a Reply or an Event) from the daemon. Description is the content of descriptions/.json.", + "oneOf": [request, server], + "$defs": defs, + }) +} + +/// The file content: pretty JSON with a final newline. +pub fn schema_text() -> String { + let mut text = + serde_json::to_string_pretty(&protocol_schema()).expect("a JSON value always serialises"); + text.push('\n'); + text +} diff --git a/crates/obsbot-proto/src/state.rs b/crates/obsbot-proto/src/state.rs new file mode 100644 index 0000000..0f2ed84 --- /dev/null +++ b/crates/obsbot-proto/src/state.rs @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The generic payload: state, patches, action parameters and path helpers (4.1, 4.2, 6.1). +//! +//! A state is a nested JSON object in the shape of the model's state tree (section 4.3 for +//! the Tiny 2 Lite), plus the daemon's fields `available`, `pending` (leaf path → written +//! value) and `unread` (leaf paths). A leaf path joins the object keys with `.`. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; + +/// Action parameters and action results: name → value. +pub type Params = Map; + +/// Leaf path → value, as a model reads one group. +pub type Values = BTreeMap; + +/// The paths the daemon adds to every state; they are never settings. +pub const DAEMON_PATHS: [&str; 3] = ["available", "pending", "unread"]; + +/// The published state of one device. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct State { + pub available: bool, + /// Leaf path → the value written; the camera accepted it but does not show it yet. + #[serde(default)] + pub pending: BTreeMap, + /// Leaf paths never read since the device last became available, or whose last read failed. + #[serde(default)] + pub unread: Vec, + /// The state tree: group → … → leaf value. + #[serde(flatten)] + pub values: Map, +} + +impl State { + /// An empty state: no values, nothing pending, nothing unread. + pub fn new(available: bool) -> State { + State { + available, + pending: BTreeMap::new(), + unread: Vec::new(), + values: Map::new(), + } + } + + /// The value at a leaf or group path of the state tree. `None` when the path is absent. + /// The daemon's fields are struct fields, not paths of the tree. + pub fn at(&self, path: &str) -> Option<&Value> { + let mut parts = path.split('.'); + let mut node = self.values.get(parts.next()?)?; + for part in parts { + node = node.as_object()?.get(part)?; + } + Some(node) + } + + /// Sets the value at a leaf path, creating the groups on the way. + pub fn set(&mut self, path: &str, value: Value) { + let parts: Vec<&str> = path.split('.').collect(); + let (last, groups) = parts.split_last().expect("split always yields one part"); + let mut map = &mut self.values; + for group in groups { + let entry = map + .entry(group.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if !entry.is_object() { + *entry = Value::Object(Map::new()); + } + map = entry.as_object_mut().expect("made an object above"); + } + map.insert(last.to_string(), value); + } + + /// A copy with the given leaves set: the state a `requires` check sees (4.2). + pub fn with(&self, leaves: &[(String, Value)]) -> State { + let mut next = self.clone(); + for (path, value) in leaves { + next.set(path, value.clone()); + } + next + } + + /// The reply of a `get` for these paths (4.1 rule 9): the paths asked for, plus + /// `available`, `pending` and `unread` in full. Absent paths are left out. + pub fn select(&self, paths: &[String]) -> State { + let mut out = State { + available: self.available, + pending: self.pending.clone(), + unread: self.unread.clone(), + values: Map::new(), + }; + for path in paths { + if DAEMON_PATHS.contains(&path.as_str()) { + continue; + } + if let Some(value) = self.at(path) { + out.set(path, value.clone()); + } + } + out + } + + /// Whether the state shows the camera asleep (`asleep: true`). + pub fn asleep(&self) -> bool { + self.at("asleep") == Some(&Value::Bool(true)) + } +} + +/// The payload of `set`: leaf paths, nested (`{"zoom":{"factor":2}}`) or dotted +/// (`{"zoom.factor":2}`). [`crate::validate::leaves`] turns it into leaves. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct Patch(pub Map); + +impl Patch { + pub fn new() -> Patch { + Patch(Map::new()) + } + + /// Adds one leaf under its dotted path. + pub fn leaf(mut self, path: &str, value: impl Into) -> Patch { + self.0.insert(path.to_string(), value.into()); + self + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample() -> State { + let mut s = State::new(true); + s.set("zoom.factor", json!(1.5)); + s.set("zone.area.pan", json!({"min": -20, "max": 20})); + s.set("asleep", json!(false)); + s.pending.insert("zoom.factor".into(), json!(2)); + s.unread.push("presets".into()); + s + } + + #[test] + fn paths_walk_the_nested_tree() { + let s = sample(); + assert_eq!(s.at("zoom.factor"), Some(&json!(1.5))); + assert_eq!( + s.at("zone.area"), + Some(&json!({"pan": {"min": -20, "max": 20}})) + ); + assert_eq!( + s.at("zone.area.pan.min"), + Some(&json!(-20)), + "a component is reachable too" + ); + assert_eq!( + s.at("gimbal.pan"), + None, + "absent paths are None, never null" + ); + assert!(!s.asleep()); + } + + #[test] + fn a_get_reply_keeps_the_daemon_fields_in_full() { + let got = sample().select(&["zoom".to_string(), "nothing.here".to_string()]); + assert_eq!(got.at("zoom.factor"), Some(&json!(1.5))); + assert_eq!(got.at("zone.area.pan"), None, "only the paths asked for"); + assert_eq!(got.pending.get("zoom.factor"), Some(&json!(2))); + assert_eq!(got.unread, vec!["presets".to_string()]); + } + + #[test] + fn with_applies_leaves_without_touching_the_original() { + let s = sample(); + let next = s.with(&[("exposure.mode".into(), json!("manual"))]); + assert_eq!(next.at("exposure.mode"), Some(&json!("manual"))); + assert_eq!(s.at("exposure.mode"), None); + } + + #[test] + fn a_patch_built_with_leaf_uses_dotted_keys() { + let p = Patch::new() + .leaf("led.brightness", 2) + .leaf("zoom.factor", 1.5); + assert_eq!( + serde_json::to_string(&p).unwrap(), + r#"{"led.brightness":2,"zoom.factor":1.5}"# + ); + } +} diff --git a/crates/obsbot-proto/src/validate.rs b/crates/obsbot-proto/src/validate.rs new file mode 100644 index 0000000..6c0c609 --- /dev/null +++ b/crates/obsbot-proto/src/validate.rs @@ -0,0 +1,635 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The validator (4.1 rule 5, 4.2, 5.6): checks a `set` patch, a `do` and a `get` against a +//! device description and its current state, before anything is sent to the camera. The +//! daemon is authoritative; clients may run the same checks. +//! +//! The whole request is checked. When several things are wrong, the refusal with the first +//! code in this order wins, and within one code the first path in path order: +//! `invalid`, `unsupported`, `out_of_range`, `conflict`, `asleep` (the order of 5.6). + +use crate::description::{Asleep, Bound, Description, Setting, SettingType, Warning}; +use crate::message::ErrorCode; +use crate::state::{DAEMON_PATHS, Params, Patch, State}; +use crate::values::{is_raw, same}; +use serde_json::Value; +use std::collections::BTreeSet; + +/// Why a request is refused. `path` names the leaf, parameter or action at fault. +#[derive(Debug, Clone, PartialEq)] +pub struct Refusal { + pub code: ErrorCode, + pub path: Option, + pub message: String, +} + +impl Refusal { + pub fn new(code: ErrorCode, path: &str, message: &str) -> Refusal { + Refusal { + code, + path: Some(path.to_string()), + message: message.to_string(), + } + } +} + +/// What a value is checked for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Fit { + /// A value a client writes: `writable[]`, bounds, step; never `null` or `raw:`. + Write, + /// A value a state may show: `values[]`, bounds; `null` where `nullable`; `raw:`. + State, +} + +fn rank(code: &ErrorCode) -> u8 { + match code { + ErrorCode::Invalid => 0, + ErrorCode::Unsupported => 1, + ErrorCode::OutOfRange => 2, + ErrorCode::Conflict => 3, + ErrorCode::Asleep => 4, + _ => 5, + } +} + +/// Keeps the refusal that wins under the order above. +#[derive(Default)] +struct First(Option); + +impl First { + fn add(&mut self, code: ErrorCode, path: &str, message: String) { + let better = match &self.0 { + None => true, + Some(old) => rank(&code) < rank(&old.code), + }; + if better { + let path = (!path.is_empty()).then(|| path.to_string()); + self.0 = Some(Refusal { + code, + path, + message, + }); + } + } + + fn done(self) -> Result<(), Refusal> { + self.0.map_or(Ok(()), Err) + } +} + +/// The leaves of a patch, nested or dotted, as (leaf path, value) in path order. Refuses an +/// empty patch, a group written as a value, a leaf named twice (`invalid`) and a path that is +/// not in the description (`unsupported`). +pub fn leaves(desc: &Description, patch: &Patch) -> Result, Refusal> { + let (out, first) = split(desc, patch); + first.done()?; + Ok(out) +} + +/// The leaves of a patch and the first structural problem, if any. +fn split(desc: &Description, patch: &Patch) -> (Vec<(String, Value)>, First) { + let mut first = First::default(); + let mut out: Vec<(String, Value)> = Vec::new(); + if patch.is_empty() { + first.add(ErrorCode::Invalid, "", "the patch is empty".into()); + } + collect(desc, "", &patch.0, &mut out, &mut first); + out.sort_by(|a, b| a.0.cmp(&b.0)); + for pair in out.windows(2) { + if pair[0].0 == pair[1].0 { + first.add( + ErrorCode::Invalid, + &pair[0].0, + format!("the patch names {} twice", pair[0].0), + ); + } + } + out.dedup_by(|a, b| a.0 == b.0); + (out, first) +} + +fn collect( + desc: &Description, + prefix: &str, + map: &serde_json::Map, + out: &mut Vec<(String, Value)>, + first: &mut First, +) { + for (key, value) in map { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + if desc.settings.contains_key(&path) { + out.push((path, value.clone())); + } else if desc.is_group(&path) { + match value.as_object() { + Some(inner) => collect(desc, &path, inner, out, first), + None => first.add( + ErrorCode::Invalid, + &path, + format!("{path} is a group, not a leaf"), + ), + } + } else { + first.add( + ErrorCode::Unsupported, + &path, + format!("{path} is not a setting of this device"), + ); + } + } +} + +/// Checks a `set` against the description and the current state; returns its leaves in path +/// order. The model's apply order is `Camera::order`, not this order. +pub fn validate_set( + desc: &Description, + state: &State, + patch: &Patch, +) -> Result, Refusal> { + let (leaves, mut first) = split(desc, patch); + for (path, value) in &leaves { + let setting = &desc.settings[path]; + if setting.readonly { + first.add(ErrorCode::Invalid, path, format!("{path} is read-only")); + } else if let Err((code, message)) = check_value(setting, value, Fit::Write) { + first.add(code, path, format!("{path}: {message}")); + } + } + let next = state.with(&leaves); + for (path, _) in &leaves { + let setting = &desc.settings[path]; + for req in &setting.requires { + if !next.at(&req.path).is_some_and(|v| same(v, &req.equals)) { + first.add( + ErrorCode::Conflict, + path, + format!("{path} requires {} = {}", req.path, req.equals), + ); + } + } + if state.asleep() && setting.asleep == Some(Asleep::Ignored) { + first.add( + ErrorCode::Asleep, + path, + format!("the camera is asleep and ignores {path}"), + ); + } + } + first.done()?; + Ok(leaves) +} + +/// Checks a `do` against the description and the current state: the action exists, every +/// parameter is known and fits, no required parameter is missing, a destructive action has +/// `confirm: true`, its `requires` hold and the camera is not asleep for an `ignored` action. +pub fn validate_do( + desc: &Description, + state: &State, + action: &str, + params: &Params, +) -> Result<(), Refusal> { + let Some(spec) = desc.actions.get(action) else { + return Err(Refusal::new( + ErrorCode::Unsupported, + action, + &format!("{action} is not an action of this device"), + )); + }; + let mut first = First::default(); + for (name, value) in params { + match spec.params.get(name) { + None => first.add( + ErrorCode::Invalid, + name, + format!("{action} has no parameter {name}"), + ), + Some(setting) => { + if let Err((code, message)) = check_value(setting, value, Fit::Write) { + first.add(code, name, format!("{action} {name}: {message}")); + } + } + } + } + for (name, setting) in &spec.params { + if !setting.optional && !params.contains_key(name) { + first.add( + ErrorCode::Invalid, + name, + format!("{action} needs the parameter {name}"), + ); + } + } + if spec.warning.contains(&Warning::Destructive) + && params.get("confirm") != Some(&Value::Bool(true)) + { + first.add( + ErrorCode::Invalid, + "confirm", + format!("{action} deletes data on the camera: send confirm: true"), + ); + } + for req in &spec.requires { + if !state.at(&req.path).is_some_and(|v| same(v, &req.equals)) { + first.add( + ErrorCode::Conflict, + action, + format!("{action} requires {} = {}", req.path, req.equals), + ); + } + } + if state.asleep() && spec.asleep == Asleep::Ignored { + first.add( + ErrorCode::Asleep, + action, + format!("the camera is asleep and ignores {action}"), + ); + } + first.done() +} + +/// Checks the paths of a `get`: each is a daemon field, a leaf or a group of the description. +pub fn validate_get(desc: &Description, paths: &[String]) -> Result<(), Refusal> { + let mut first = First::default(); + for path in paths { + let known = DAEMON_PATHS.contains(&path.as_str()) + || desc.settings.contains_key(path) + || desc.is_group(path); + if !known { + first.add( + ErrorCode::Unsupported, + path, + format!("{path} is not a path of this device"), + ); + } + } + first.done() +} + +fn as_number(v: &Value) -> Option { + v.as_f64().filter(|x| x.is_finite()) +} + +const EPS: f64 = 1e-9; + +fn check_number( + s: &Setting, + x: f64, + component: Option<&str>, + fit: Fit, +) -> Result<(), (ErrorCode, String)> { + let bound = |b: &Option| b.as_ref().and_then(|b| b.get(component)); + let what = component.map_or(String::new(), |c| format!("{c} ")); + if let Some(min) = bound(&s.min) + && x < min - EPS + { + return Err(( + ErrorCode::OutOfRange, + format!("{what}{x} is below the minimum {min}"), + )); + } + if let Some(max) = bound(&s.max) + && x > max + EPS + { + return Err(( + ErrorCode::OutOfRange, + format!("{what}{x} is above the maximum {max}"), + )); + } + if fit == Fit::Write + && let Some(step) = bound(&s.step).filter(|st| *st > 0.0) + { + let k = (x - bound(&s.min).unwrap_or(0.0)) / step; + if (k - k.round()).abs() > 1e-6 { + return Err(( + ErrorCode::OutOfRange, + format!("{what}{x} is not a multiple of the step {step}"), + )); + } + } + Ok(()) +} + +/// Checks one value against one setting. The error carries the code and an English message +/// without the path. +pub fn check_value(s: &Setting, v: &Value, fit: Fit) -> Result<(), (ErrorCode, String)> { + if v.is_null() { + return if fit == Fit::State && s.nullable { + Ok(()) + } else { + Err(( + ErrorCode::Invalid, + "null is not a value of this setting".into(), + )) + }; + } + if fit == Fit::State && v.as_str().is_some_and(is_raw) { + return Ok(()); + } + let invalid = |m: &str| Err((ErrorCode::Invalid, m.to_string())); + match &s.kind { + SettingType::Bool => { + if v.is_boolean() { + Ok(()) + } else { + invalid("expected true or false") + } + } + SettingType::Enum => { + let Some(text) = v.as_str() else { + return invalid("expected one of the names of this setting"); + }; + let allowed = if fit == Fit::Write { + &s.writable + } else { + &s.values + }; + if allowed.iter().any(|a| a == text) { + Ok(()) + } else { + Err(( + ErrorCode::OutOfRange, + format!("{text} is not one of {}", allowed.join("|")), + )) + } + } + SettingType::Number => match as_number(v) { + Some(x) => check_number(s, x, None, fit), + None => invalid("expected a number"), + }, + SettingType::String => { + let Some(text) = v.as_str() else { + return invalid("expected a string"); + }; + if let Some(max) = s.max_len + && text.chars().count() > max as usize + { + return Err(( + ErrorCode::OutOfRange, + format!("longer than {max} characters"), + )); + } + if s.ascii && !text.bytes().all(|b| (0x20..=0x7e).contains(&b)) { + return Err(( + ErrorCode::OutOfRange, + "only printable ASCII characters are allowed".into(), + )); + } + Ok(()) + } + SettingType::List => { + if fit == Fit::Write { + return invalid("a list is read-only"); + } + let Some(items) = v.as_array() else { + return invalid("expected a list"); + }; + let fields = s.item.clone().unwrap_or_default(); + for item in items { + let Some(obj) = item.as_object() else { + return invalid("expected a list of objects"); + }; + for (name, field) in &fields { + let value = obj.get(name).unwrap_or(&Value::Null); + check_value(field, value, Fit::State) + .map_err(|(c, m)| (c, format!("item {name}: {m}")))?; + } + } + Ok(()) + } + SettingType::Other(kind) => { + if fit == Fit::State { + Ok(()) + } else { + invalid(&format!("a setting of type {kind} cannot be written")) + } + } + kind => { + let names = kind + .components() + .expect("the remaining types are object-shaped"); + let Some(obj) = v.as_object() else { + return invalid(&format!("expected an object with {}", names.join(", "))); + }; + let keys: BTreeSet<&str> = obj.keys().map(String::as_str).collect(); + if keys != names.iter().copied().collect() { + return invalid(&format!("expected exactly the fields {}", names.join(", "))); + } + for name in names { + let Some(x) = as_number(&obj[*name]) else { + return invalid(&format!("{name} must be a number")); + }; + check_number(s, x, Some(name), fit)?; + } + if *kind == SettingType::Range && as_number(&obj["min"]) > as_number(&obj["max"]) { + return Err((ErrorCode::OutOfRange, "min is above max".into())); + } + Ok(()) + } + } +} + +fn path_ok(path: &str) -> bool { + !path.is_empty() + && path.split('.').all(|seg| { + let mut bytes = seg.bytes(); + bytes.next().is_some_and(|b| b.is_ascii_lowercase()) + && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') + }) +} + +fn check_setting( + where_: &str, + s: &Setting, + desc: &Description, + leaf: bool, + problems: &mut Vec, +) { + let mut bad = |m: &str| problems.push(format!("{where_}: {m}")); + if s.label.is_empty() { + bad("has no label"); + } + match &s.kind { + SettingType::Enum => { + if s.values.is_empty() { + bad("an enum without values"); + } + if s.writable.iter().any(|w| !s.values.contains(w)) { + bad("writable holds a value that values does not"); + } + if leaf && s.readonly && !s.writable.is_empty() { + bad("a read-only enum with writable values"); + } + if (!leaf || !s.readonly) && s.writable.is_empty() { + bad("a writable enum without writable values"); + } + } + SettingType::Number => { + for (name, b) in [ + ("min", &s.min), + ("max", &s.max), + ("step", &s.step), + ("precision", &s.precision), + ] { + if matches!(b, Some(Bound::Each(_))) { + bad(&format!("{name} of a number must be one number")); + } + } + } + SettingType::List => { + if !s.readonly || s.item.is_none() { + bad("a list must be read-only and describe its item"); + } + } + kind => { + if let Some(names) = kind.components() { + for (name, b) in [("min", &s.min), ("max", &s.max)] { + if let Some(Bound::Each(map)) = b { + let keys: BTreeSet<&str> = map.keys().map(String::as_str).collect(); + if keys != names.iter().copied().collect() { + bad(&format!( + "{name} must have exactly the fields {}", + names.join(", ") + )); + } + } else if b.is_some() { + bad(&format!( + "{name} of an object-shaped setting must be an object" + )); + } + } + } + } + } + let components: Vec> = match s.kind.components() { + Some(names) => names.iter().map(|n| Some(*n)).collect(), + None => vec![None], + }; + for c in components { + if let (Some(lo), Some(hi)) = ( + s.min.as_ref().and_then(|b| b.get(c)), + s.max.as_ref().and_then(|b| b.get(c)), + ) && lo > hi + { + bad("min is above max"); + } + } + if let Some(default) = &s.default + && check_value(s, default, Fit::State).is_err() + { + bad("the default does not fit the setting"); + } + for req in &s.requires { + match desc.settings.get(&req.path) { + None => bad(&format!("requires the unknown path {}", req.path)), + Some(target) => { + if check_value(target, &req.equals, Fit::State).is_err() { + bad(&format!("requires a value that {} cannot have", req.path)); + } + } + } + } + if let Some(topic) = &s.live + && !path_ok(topic) + { + bad("the live topic is not a lower-case name"); + } + if leaf { + if s.optional { + bad("optional is for action parameters only"); + } + if s.readonly && (s.asleep.is_some() || s.settle_ms.is_some()) { + bad("a read-only leaf has neither asleep nor settle_ms"); + } + if !s.readonly && s.asleep.is_none() { + bad("a writable leaf needs asleep"); + } + if !s.readonly && s.readback && s.settle_ms.is_none() { + bad("a writable leaf with a readback needs settle_ms"); + } + if !s.readback && s.settle_ms.is_some() { + bad("a leaf without readback has no settle_ms"); + } + } +} + +/// Checks that a description is consistent in itself; returns the problems, empty when there +/// are none. Every model and fake must pass (trait conformance, 10.3 W0). +pub fn check_description(desc: &Description) -> Vec { + let mut problems = Vec::new(); + for (path, setting) in &desc.settings { + if !path_ok(path) { + problems.push(format!( + "{path}: not lower-case snake_case segments joined by '.'" + )); + } + if DAEMON_PATHS.contains(&path.as_str()) { + problems.push(format!("{path}: reserved for the daemon")); + } + if desc.is_group(path) { + problems.push(format!("{path}: a leaf that is also a group")); + } + check_setting(path, setting, desc, true, &mut problems); + for (field, item) in setting.item.iter().flatten() { + check_setting( + &format!("{path}[].{field}"), + item, + desc, + false, + &mut problems, + ); + } + } + for (name, action) in &desc.actions { + if !path_ok(name) { + problems.push(format!( + "{name}: not lower-case snake_case segments joined by '.'" + )); + } + if action.label.is_empty() { + problems.push(format!("{name}: has no label")); + } + for (param, setting) in action.params.iter().chain(action.result.iter().flatten()) { + if !path_ok(param) || param.contains('.') { + problems.push(format!( + "{name}.{param}: a parameter name is one lower-case segment" + )); + } + check_setting( + &format!("{name}.{param}"), + setting, + desc, + false, + &mut problems, + ); + } + if action.warning.contains(&Warning::Destructive) + && action + .params + .get("confirm") + .is_none_or(|c| c.kind != SettingType::Bool) + { + problems.push(format!( + "{name}: a destructive action needs the bool parameter confirm" + )); + } + for req in &action.requires { + if !desc.settings.contains_key(&req.path) { + problems.push(format!("{name}: requires the unknown path {}", req.path)); + } + } + } + for list in desc.limits.keys() { + if desc + .settings + .get(list) + .is_none_or(|s| s.kind != SettingType::List) + { + problems.push(format!("limits.{list}: not a list setting")); + } + } + problems +} diff --git a/crates/obsbot-proto/src/values.rs b/crates/obsbot-proto/src/values.rs new file mode 100644 index 0000000..0e75df4 --- /dev/null +++ b/crates/obsbot-proto/src/values.rs @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Value helpers shared by the daemon, the models and the clients: canonical numbers, +//! rounding to a precision (4.2 "Precision"), `raw:` for undecodable values and value +//! equality that treats `1` and `1.0` as the same number. + +use serde_json::{Number, Value}; + +/// A JSON number in canonical form: an integral value within ±2^53 is written as an integer +/// (`3`, not `3.0`), anything else as a float. Non-finite input gives `null`. +pub fn number(x: f64) -> Value { + if !x.is_finite() { + return Value::Null; + } + if x.fract() == 0.0 && x.abs() < 9_007_199_254_740_992.0 { + return Value::Number(Number::from(x as i64)); + } + Number::from_f64(x).map_or(Value::Null, Value::Number) +} + +/// `x` rounded to `precision` (0.1, 0.01, 1, 100 …) in canonical form, so that state and +/// `--json` never show binary float noise. +pub fn rounded(x: f64, precision: f64) -> Value { + if precision <= 0.0 { + return number(x); + } + let steps = (x / precision).round(); + let decimals = (-precision.log10()).ceil().max(0.0) as i32; + let scale = 10f64.powi(decimals); + number((steps * precision * scale).round() / scale) +} + +/// The published form of a camera value the model cannot decode: `raw:` (4.2). +pub fn raw(n: i64) -> Value { + Value::String(format!("raw:{n}")) +} + +/// Whether a string is a `raw:` value. +pub fn is_raw(text: &str) -> bool { + text.strip_prefix("raw:").is_some_and(|n| { + !n.is_empty() + && n.trim_start_matches('-') + .bytes() + .all(|b| b.is_ascii_digit()) + }) +} + +/// Value equality in which numbers compare by value (`1` equals `1.0`), recursively. +pub fn same(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => x.as_f64() == y.as_f64(), + (Value::Array(x), Value::Array(y)) => { + x.len() == y.len() && x.iter().zip(y).all(|(p, q)| same(p, q)) + } + (Value::Object(x), Value::Object(y)) => { + x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same(v, w))) + } + _ => a == b, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn integral_numbers_are_written_without_a_fraction() { + assert_eq!(number(3.0).to_string(), "3"); + assert_eq!(number(-150.0).to_string(), "-150"); + assert_eq!(number(1.5).to_string(), "1.5"); + assert_eq!(number(f64::NAN), Value::Null); + } + + #[test] + fn rounding_removes_float_noise() { + assert_eq!(rounded(0.1 + 0.2, 0.1).to_string(), "0.3"); + assert_eq!(rounded(12.345_678, 0.01).to_string(), "12.35"); + assert_eq!(rounded(-30.04, 0.1).to_string(), "-30"); + assert_eq!(rounded(1234.0, 100.0).to_string(), "1200"); + } + + #[test] + fn raw_values_are_recognised() { + assert_eq!(raw(3), json!("raw:3")); + assert!(is_raw("raw:3")); + assert!(is_raw("raw:-1")); + assert!(!is_raw("raw:")); + assert!(!is_raw("raw:x")); + assert!(!is_raw("auto")); + } + + #[test] + fn numbers_compare_by_value() { + assert!(same(&json!(1), &json!(1.0))); + assert!(same( + &json!({"min": 1, "max": 2}), + &json!({"max": 2.0, "min": 1.0}) + )); + assert!(!same(&json!(1), &json!("1"))); + assert!(!same(&json!([1]), &json!([1, 2]))); + } +} diff --git a/crates/obsbot-proto/tests/golden/errors.jsonl b/crates/obsbot-proto/tests/golden/errors.jsonl new file mode 100644 index 0000000..122256e --- /dev/null +++ b/crates/obsbot-proto/tests/golden/errors.jsonl @@ -0,0 +1,13 @@ +# One reply per error code the daemon sends, in the order of the table in 4.1. +< {"id":null,"error":{"code":"protocol","message":"the first message must be hello","supported":[2]}} +< {"id":2,"error":{"code":"invalid","message":"led.brightness: expected a number"}} +< {"id":3,"error":{"code":"unknown_device","message":"no device sn:EXAMPLE0000009"}} +< {"id":4,"error":{"code":"unsupported","message":"tracking.mode is not a setting of this device"}} +< {"id":5,"error":{"code":"unsupported_device","message":"port:3-2.4 is not supported by obsbot4linux"}} +< {"id":6,"error":{"code":"out_of_range","message":"led.brightness: 4 is above the maximum 3"}} +< {"id":7,"error":{"code":"conflict","message":"preset slot 2 is empty"}} +< {"id":8,"error":{"code":"unavailable","message":"sn:EXAMPLE0000001 is unplugged"}} +< {"id":9,"error":{"code":"asleep","message":"the camera is asleep and ignores zoom.field_of_view"}} +< {"id":10,"error":{"code":"busy","message":"32 requests are queued for sn:EXAMPLE0000001"}} +< {"id":11,"error":{"code":"camera","message":"the camera did not answer","rev":9,"applied":["exposure.mode"],"failed":"exposure.time"}} +< {"id":12,"error":{"code":"internal","message":"a frame outside the send table was refused"}} diff --git a/crates/obsbot-proto/tests/golden/hotplug.jsonl b/crates/obsbot-proto/tests/golden/hotplug.jsonl new file mode 100644 index 0000000..753ebcc --- /dev/null +++ b/crates/obsbot-proto/tests/golden/hotplug.jsonl @@ -0,0 +1,16 @@ +# Hotplug and the identity hand-over, as a subscribed client sees them (4.1 Events, 5.3 to 5.5). +# 1. An unsupported device is plugged in: devices, then its state. +< {"event":"devices","devices":[{"id":"sn:EXAMPLE0000001","name":"Example Camera 0001","model":"example","support":"tested","serial":"EXAMPLE0000001","firmware":"1.2.3.4","video_node":"/dev/video2","available":true,"usb":{"vendor":"3564","product":"fa01","product_name":"Example Camera","bcd_device":"0100","path":"3-2.1","serial_present":false,"interfaces":["0e","0e"],"extension_units":[{"unit":2,"guid":"00000000-0000-0000-0000-000000000001","controls":12}]},"settings":{"asleep":{"type":"bool","label":"Asleep","readonly":true,"nullable":false,"readback":true,"no_effect":false},"gimbal.pan":{"type":"number","label":"Pan","readonly":true,"nullable":false,"readback":true,"no_effect":false,"unit":"deg","precision":0.1,"live":"pose"},"led.brightness":{"type":"number","label":"LED brightness","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"min":0,"max":3,"step":1,"settle_ms":3000},"presets":{"type":"list","label":"Presets","readonly":true,"nullable":false,"readback":true,"no_effect":false,"item":{"name":{"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true},"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}}}},"actions":{"preset.recall":{"label":"Recall a preset","params":{"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}},"asleep":"unknown"}},"limits":{"presets":3}},{"id":"port:3-2.4","name":"USB 3564:fa02 3-2.4","model":"unknown","support":"unsupported","serial":null,"firmware":null,"video_node":"/dev/video6","available":true,"usb":{"vendor":"3564","product":"fa02","product_name":null,"bcd_device":"0100","path":"3-2.4","serial_present":false,"interfaces":["0e","0e"],"extension_units":[]},"settings":{},"actions":{},"limits":{}}]} +< {"event":"state","device":"port:3-2.4","rev":1,"state":{"available":true,"pending":{},"unread":[]}} +# 2. The sn: device is unplugged: it stays listed, then one state with available:false and its last values. +< {"event":"devices","devices":[{"id":"sn:EXAMPLE0000001","name":"Example Camera 0001","model":"example","support":"tested","serial":"EXAMPLE0000001","firmware":"1.2.3.4","video_node":null,"available":false,"usb":{"vendor":"3564","product":"fa01","product_name":"Example Camera","bcd_device":"0100","path":"3-2.1","serial_present":false,"interfaces":["0e","0e"],"extension_units":[{"unit":2,"guid":"00000000-0000-0000-0000-000000000001","controls":12}]},"settings":{"asleep":{"type":"bool","label":"Asleep","readonly":true,"nullable":false,"readback":true,"no_effect":false},"gimbal.pan":{"type":"number","label":"Pan","readonly":true,"nullable":false,"readback":true,"no_effect":false,"unit":"deg","precision":0.1,"live":"pose"},"led.brightness":{"type":"number","label":"LED brightness","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"min":0,"max":3,"step":1,"settle_ms":3000},"presets":{"type":"list","label":"Presets","readonly":true,"nullable":false,"readback":true,"no_effect":false,"item":{"name":{"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true},"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}}}},"actions":{"preset.recall":{"label":"Recall a preset","params":{"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}},"asleep":"unknown"}},"limits":{"presets":3}},{"id":"port:3-2.4","name":"USB 3564:fa02 3-2.4","model":"unknown","support":"unsupported","serial":null,"firmware":null,"video_node":"/dev/video6","available":true,"usb":{"vendor":"3564","product":"fa02","product_name":null,"bcd_device":"0100","path":"3-2.4","serial_present":false,"interfaces":["0e","0e"],"extension_units":[]},"settings":{},"actions":{},"limits":{}}]} +< {"event":"state","device":"sn:EXAMPLE0000001","rev":8,"state":{"available":false,"pending":{},"unread":[],"asleep":false,"gimbal":{"pan":-20},"led":{"brightness":2},"presets":[{"name":"Desk","slot":0}]}} +# 3. The unsupported port: entry is unplugged: it is removed, and no state event follows. +< {"event":"devices","devices":[{"id":"sn:EXAMPLE0000001","name":"Example Camera 0001","model":"example","support":"tested","serial":"EXAMPLE0000001","firmware":"1.2.3.4","video_node":null,"available":false,"usb":{"vendor":"3564","product":"fa01","product_name":"Example Camera","bcd_device":"0100","path":"3-2.1","serial_present":false,"interfaces":["0e","0e"],"extension_units":[{"unit":2,"guid":"00000000-0000-0000-0000-000000000001","controls":12}]},"settings":{"asleep":{"type":"bool","label":"Asleep","readonly":true,"nullable":false,"readback":true,"no_effect":false},"gimbal.pan":{"type":"number","label":"Pan","readonly":true,"nullable":false,"readback":true,"no_effect":false,"unit":"deg","precision":0.1,"live":"pose"},"led.brightness":{"type":"number","label":"LED brightness","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"min":0,"max":3,"step":1,"settle_ms":3000},"presets":{"type":"list","label":"Presets","readonly":true,"nullable":false,"readback":true,"no_effect":false,"item":{"name":{"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true},"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}}}},"actions":{"preset.recall":{"label":"Recall a preset","params":{"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}},"asleep":"unknown"}},"limits":{"presets":3}}]} +# 4. The camera comes back; its serial is not answered at once, so it enters as port:3-2.1. +< {"event":"devices","devices":[{"id":"sn:EXAMPLE0000001","name":"Example Camera 0001","model":"example","support":"tested","serial":"EXAMPLE0000001","firmware":"1.2.3.4","video_node":null,"available":false,"usb":{"vendor":"3564","product":"fa01","product_name":"Example Camera","bcd_device":"0100","path":"3-2.1","serial_present":false,"interfaces":["0e","0e"],"extension_units":[{"unit":2,"guid":"00000000-0000-0000-0000-000000000001","controls":12}]},"settings":{"asleep":{"type":"bool","label":"Asleep","readonly":true,"nullable":false,"readback":true,"no_effect":false},"gimbal.pan":{"type":"number","label":"Pan","readonly":true,"nullable":false,"readback":true,"no_effect":false,"unit":"deg","precision":0.1,"live":"pose"},"led.brightness":{"type":"number","label":"LED brightness","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"min":0,"max":3,"step":1,"settle_ms":3000},"presets":{"type":"list","label":"Presets","readonly":true,"nullable":false,"readback":true,"no_effect":false,"item":{"name":{"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true},"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}}}},"actions":{"preset.recall":{"label":"Recall a preset","params":{"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}},"asleep":"unknown"}},"limits":{"presets":3}},{"id":"port:3-2.1","name":"Example Camera 3-2.1","model":"example","support":"tested","serial":null,"firmware":null,"video_node":"/dev/video2","available":true,"usb":{"vendor":"3564","product":"fa01","product_name":"Example Camera","bcd_device":"0100","path":"3-2.1","serial_present":false,"interfaces":["0e","0e"],"extension_units":[{"unit":2,"guid":"00000000-0000-0000-0000-000000000001","controls":12}]},"settings":{"asleep":{"type":"bool","label":"Asleep","readonly":true,"nullable":false,"readback":true,"no_effect":false},"gimbal.pan":{"type":"number","label":"Pan","readonly":true,"nullable":false,"readback":true,"no_effect":false,"unit":"deg","precision":0.1,"live":"pose"},"led.brightness":{"type":"number","label":"LED brightness","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"min":0,"max":3,"step":1,"settle_ms":3000},"presets":{"type":"list","label":"Presets","readonly":true,"nullable":false,"readback":true,"no_effect":false,"item":{"name":{"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true},"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}}}},"actions":{"preset.recall":{"label":"Recall a preset","params":{"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}},"asleep":"unknown"}},"limits":{"presets":3}}]} +< {"event":"state","device":"port:3-2.1","rev":1,"state":{"available":true,"pending":{},"unread":[],"asleep":false,"gimbal":{"pan":-20},"led":{"brightness":2},"presets":[{"name":"Desk","slot":0}]}} +# 5. A later identity attempt answers the serial: the hand-over, three events in a row (5.5). +< {"event":"devices","devices":[{"id":"sn:EXAMPLE0000001","name":"Example Camera 0001","model":"example","support":"tested","serial":"EXAMPLE0000001","firmware":"1.2.3.4","video_node":"/dev/video2","available":true,"usb":{"vendor":"3564","product":"fa01","product_name":"Example Camera","bcd_device":"0100","path":"3-2.1","serial_present":false,"interfaces":["0e","0e"],"extension_units":[{"unit":2,"guid":"00000000-0000-0000-0000-000000000001","controls":12}]},"settings":{"asleep":{"type":"bool","label":"Asleep","readonly":true,"nullable":false,"readback":true,"no_effect":false},"gimbal.pan":{"type":"number","label":"Pan","readonly":true,"nullable":false,"readback":true,"no_effect":false,"unit":"deg","precision":0.1,"live":"pose"},"led.brightness":{"type":"number","label":"LED brightness","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"min":0,"max":3,"step":1,"settle_ms":3000},"presets":{"type":"list","label":"Presets","readonly":true,"nullable":false,"readback":true,"no_effect":false,"item":{"name":{"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true},"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}}}},"actions":{"preset.recall":{"label":"Recall a preset","params":{"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}},"asleep":"unknown"}},"limits":{"presets":3}},{"id":"port:3-2.1","name":"Example Camera 3-2.1","model":"example","support":"tested","serial":null,"firmware":null,"video_node":"/dev/video2","available":false,"replaced_by":"sn:EXAMPLE0000001","usb":{"vendor":"3564","product":"fa01","product_name":"Example Camera","bcd_device":"0100","path":"3-2.1","serial_present":false,"interfaces":["0e","0e"],"extension_units":[{"unit":2,"guid":"00000000-0000-0000-0000-000000000001","controls":12}]},"settings":{"asleep":{"type":"bool","label":"Asleep","readonly":true,"nullable":false,"readback":true,"no_effect":false},"gimbal.pan":{"type":"number","label":"Pan","readonly":true,"nullable":false,"readback":true,"no_effect":false,"unit":"deg","precision":0.1,"live":"pose"},"led.brightness":{"type":"number","label":"LED brightness","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"min":0,"max":3,"step":1,"settle_ms":3000},"presets":{"type":"list","label":"Presets","readonly":true,"nullable":false,"readback":true,"no_effect":false,"item":{"name":{"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true},"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}}}},"actions":{"preset.recall":{"label":"Recall a preset","params":{"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}},"asleep":"unknown"}},"limits":{"presets":3}}]} +< {"event":"devices","devices":[{"id":"sn:EXAMPLE0000001","name":"Example Camera 0001","model":"example","support":"tested","serial":"EXAMPLE0000001","firmware":"1.2.3.4","video_node":"/dev/video2","available":true,"usb":{"vendor":"3564","product":"fa01","product_name":"Example Camera","bcd_device":"0100","path":"3-2.1","serial_present":false,"interfaces":["0e","0e"],"extension_units":[{"unit":2,"guid":"00000000-0000-0000-0000-000000000001","controls":12}]},"settings":{"asleep":{"type":"bool","label":"Asleep","readonly":true,"nullable":false,"readback":true,"no_effect":false},"gimbal.pan":{"type":"number","label":"Pan","readonly":true,"nullable":false,"readback":true,"no_effect":false,"unit":"deg","precision":0.1,"live":"pose"},"led.brightness":{"type":"number","label":"LED brightness","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"min":0,"max":3,"step":1,"settle_ms":3000},"presets":{"type":"list","label":"Presets","readonly":true,"nullable":false,"readback":true,"no_effect":false,"item":{"name":{"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true},"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}}}},"actions":{"preset.recall":{"label":"Recall a preset","params":{"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}},"asleep":"unknown"}},"limits":{"presets":3}}]} +< {"event":"state","device":"sn:EXAMPLE0000001","rev":9,"state":{"available":true,"pending":{},"unread":[],"asleep":false,"gimbal":{"pan":-20},"led":{"brightness":2},"presets":[{"name":"Desk","slot":0}]}} diff --git a/crates/obsbot-proto/tests/golden/session.jsonl b/crates/obsbot-proto/tests/golden/session.jsonl new file mode 100644 index 0000000..dfd2ac2 --- /dev/null +++ b/crates/obsbot-proto/tests/golden/session.jsonl @@ -0,0 +1,23 @@ +# A widget connects, writes one leaf, reads it back, recalls a preset and changes its live list. +# Example device sn:EXAMPLE0000001 (model "example") and one unsupported device port:3-2.4. +> {"id":1,"hello":{"protocol":2,"client":"widget 1.0","subscribe":true,"live":["pose"]}} +< {"id":1,"ok":{"protocol":2,"daemon":"1.0.0","devices":[{"id":"sn:EXAMPLE0000001","name":"Example Camera 0001","model":"example","support":"tested","serial":"EXAMPLE0000001","firmware":"1.2.3.4","video_node":"/dev/video2","available":true,"usb":{"vendor":"3564","product":"fa01","product_name":"Example Camera","bcd_device":"0100","path":"3-2.1","serial_present":false,"interfaces":["0e","0e"],"extension_units":[{"unit":2,"guid":"00000000-0000-0000-0000-000000000001","controls":12}]},"settings":{"asleep":{"type":"bool","label":"Asleep","readonly":true,"nullable":false,"readback":true,"no_effect":false},"gimbal.pan":{"type":"number","label":"Pan","readonly":true,"nullable":false,"readback":true,"no_effect":false,"unit":"deg","precision":0.1,"live":"pose"},"led.brightness":{"type":"number","label":"LED brightness","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"min":0,"max":3,"step":1,"settle_ms":3000},"presets":{"type":"list","label":"Presets","readonly":true,"nullable":false,"readback":true,"no_effect":false,"item":{"name":{"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true},"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}}}},"actions":{"preset.recall":{"label":"Recall a preset","params":{"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}},"asleep":"unknown"}},"limits":{"presets":3}},{"id":"port:3-2.4","name":"USB 3564:fa02 3-2.4","model":"unknown","support":"unsupported","serial":null,"firmware":null,"video_node":"/dev/video6","available":true,"usb":{"vendor":"3564","product":"fa02","product_name":null,"bcd_device":"0100","path":"3-2.4","serial_present":false,"interfaces":["0e","0e"],"extension_units":[]},"settings":{},"actions":{},"limits":{}}],"states":{"port:3-2.4":{"rev":1,"state":{"available":true,"pending":{},"unread":[]}},"sn:EXAMPLE0000001":{"rev":4,"state":{"available":true,"pending":{},"unread":[],"asleep":false,"gimbal":{"pan":12.5},"led":{"brightness":1},"presets":[{"name":"Desk","slot":0}]}}}}} +# The write is accepted; the touched re-read does not show it yet, so it is pending (4.1 rules 3 and 4). +> {"id":2,"device":"sn:EXAMPLE0000001","set":{"led.brightness":2}} +< {"event":"state","device":"sn:EXAMPLE0000001","rev":5,"state":{"available":true,"pending":{"led.brightness":2},"unread":[],"asleep":false,"gimbal":{"pan":12.5},"led":{"brightness":1},"presets":[{"name":"Desk","slot":0}]}} +< {"id":2,"ok":{"rev":5}} +# The next read of the group shows the value: it leaves pending. +< {"event":"state","device":"sn:EXAMPLE0000001","rev":6,"state":{"available":true,"pending":{},"unread":[],"asleep":false,"gimbal":{"pan":12.5},"led":{"brightness":2},"presets":[{"name":"Desk","slot":0}]}} +> {"id":3,"device":"sn:EXAMPLE0000001","get":["led"]} +< {"id":3,"ok":{"rev":6,"state":{"available":true,"pending":{},"unread":[],"led":{"brightness":2}}}} +# A motion action replies on acceptance; the pose then moves through later events (4.1 rule 6). +> {"id":4,"device":"sn:EXAMPLE0000001","do":"preset.recall","with":{"slot":0}} +< {"id":4,"ok":{"rev":6}} +< {"event":"state","device":"sn:EXAMPLE0000001","rev":7,"state":{"available":true,"pending":{},"unread":[],"asleep":false,"gimbal":{"pan":-20},"led":{"brightness":2},"presets":[{"name":"Desk","slot":0}]}} +> {"id":5,"live":["pose","target"]} +< {"id":5,"ok":{}} +# Requests for the unsupported device are answered at once (4.1 rule 2, 5.4). +> {"id":6,"device":"port:3-2.4","get":null} +< {"id":6,"ok":{"rev":1,"state":{"available":true,"pending":{},"unread":[]}}} +> {"id":7,"device":"port:3-2.4","set":{"led.brightness":2}} +< {"id":7,"error":{"code":"unsupported_device","message":"port:3-2.4 is not supported by obsbot4linux"}} diff --git a/crates/obsbot-proto/tests/golden/types.jsonl b/crates/obsbot-proto/tests/golden/types.jsonl new file mode 100644 index 0000000..8517274 --- /dev/null +++ b/crates/obsbot-proto/tests/golden/types.jsonl @@ -0,0 +1,65 @@ +# One line per case: the type name, a space, the canonical JSON of one value of that type. +# Every line must parse into its type and serialise back byte for byte (tests/types.rs). +RequestId 7 +RequestId "a-7" +Support "tested" +Support "untested" +Support "unsupported" +Support "beta" +Unit "deg/s" +Unit "K" +Unit "lux" +UnitSpec "fraction" +UnitSpec {"pan":"deg","tilt":"deg","zoom":"factor"} +Bound 0.01 +Bound {"max":150,"min":-150} +Asleep "ignored" +Asleep "sometimes" +Warning "destructive" +Warning "explode" +SettingType "box" +SettingType "matrix" +ErrorCode "out_of_range" +ErrorCode "rate_limited" +Requirement {"path":"exposure.mode","equals":"manual"} +ExtensionUnit {"unit":2,"guid":"00000000-0000-0000-0000-000000000001","controls":12} +Usb {"vendor":"3564","product":"fa02","product_name":null,"bcd_device":"0100","path":"3-2.1","serial_present":false,"interfaces":["0e","0e","01","01"],"extension_units":[{"unit":2,"guid":"00000000-0000-0000-0000-000000000001","controls":12}]} +Setting {"type":"bool","label":"Invert pan direction","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"settle_ms":3000} +Setting {"type":"enum","label":"Field of view","readonly":false,"nullable":false,"readback":true,"asleep":"ignored","no_effect":false,"values":["wide","medium","narrow","custom"],"writable":["wide","medium","narrow"],"settle_ms":3000} +Setting {"type":"number","label":"Exposure time","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"min":100,"max":250000,"unit":"us","precision":1,"requires":[{"path":"exposure.mode","equals":"manual"}],"default":33000,"live":"auto","settle_ms":3000} +Setting {"type":"range","label":"Zone pan","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"min":{"max":-150,"min":-150},"max":{"max":150,"min":150},"unit":"deg","precision":0.1,"settle_ms":3000} +Setting {"type":"pose","label":"Hand return pose","readonly":true,"nullable":true,"readback":true,"no_effect":false,"min":{"pan":-150,"tilt":-70,"zoom":1},"max":{"pan":150,"tilt":90,"zoom":4},"unit":{"pan":"deg","tilt":"deg","zoom":"factor"},"precision":{"pan":0.1,"tilt":0.1,"zoom":0.01}} +Setting {"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true,"optional":true} +Setting {"type":"list","label":"Presets","readonly":true,"nullable":false,"readback":true,"no_effect":false,"item":{"name":{"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true},"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}}} +Setting {"type":"bool","label":"LED blink","readonly":false,"nullable":false,"readback":false,"asleep":"unknown","no_effect":false} +Setting {"type":"bool","label":"Microphone","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"settle_ms":3000,"warning":["restart"]} +Setting {"type":"number","label":"Wake zoom","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":true,"min":1,"max":4,"unit":"factor","precision":0.01,"settle_ms":3000} +Setting {"type":"matrix","label":"Colour matrix","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"settle_ms":3000} +Action {"label":"Stop the gimbal","params":{},"asleep":"unknown"} +Action {"label":"Save a view","params":{"name":{"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true}},"result":{"id":{"type":"number","label":"View id","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"step":1}},"asleep":"ignored"} +Action {"label":"Factory reset","params":{"confirm":{"type":"bool","label":"Confirm","readonly":false,"nullable":false,"readback":true,"no_effect":false}},"asleep":"unknown","warning":["destructive","restart"]} +Description {"settings":{"led.blink":{"type":"bool","label":"LED blink","readonly":false,"nullable":false,"readback":false,"asleep":"unknown","no_effect":false}},"actions":{"gimbal.stop":{"label":"Stop the gimbal","params":{},"asleep":"unknown"}},"limits":{"presets":3}} +Device {"id":"port:3-2.1","name":"USB 3564:fa02 3-2.1","model":"unknown","support":"unsupported","serial":null,"firmware":null,"video_node":"/dev/video4","available":true,"usb":{"vendor":"3564","product":"fa02","product_name":null,"bcd_device":"0100","path":"3-2.1","serial_present":false,"interfaces":["0e","0e"],"extension_units":[]},"settings":{},"actions":{},"limits":{}} +Device {"id":"port:3-2.2","name":"Example Camera 3-2.2","model":"example","support":"tested","serial":null,"firmware":null,"video_node":null,"available":false,"replaced_by":"sn:EXAMPLE0000001","usb":{"vendor":"3564","product":"fa01","product_name":"Example Camera","bcd_device":"0100","path":"3-2.2","serial_present":false,"interfaces":["0e"],"extension_units":[]},"settings":{},"actions":{},"limits":{}} +State {"available":true,"pending":{"led.brightness":2},"unread":["presets"],"asleep":false,"led":{"brightness":1},"presets":null,"zone":{"area":{"pan":{"max":20,"min":-20}}}} +Patch {"exposure":{"mode":"manual","time":20000},"led.brightness":2} +Hello {"protocol":2,"client":"widget 1.0","subscribe":true,"live":["pose"]} +Request {"id":1,"hello":{"protocol":2,"client":"obsbot4linux 1.0.0","subscribe":false,"live":[]}} +Request {"id":2,"device":"sn:EXAMPLE0000001","get":null} +Request {"id":3,"device":"sn:EXAMPLE0000001","get":["zoom.factor","presets"]} +Request {"id":4,"device":"sn:EXAMPLE0000001","set":{"zone":{"area":{"pan":{"max":20,"min":-20}}}}} +Request {"id":"v","device":"sn:EXAMPLE0000001","do":"view.save","with":{"name":"Desk","pose":{"pan":0,"tilt":5,"zoom":1.2}}} +Request {"id":6,"device":"sn:EXAMPLE0000001","do":"gimbal.stop"} +Request {"id":7,"live":["pose","target"]} +Reply {"id":1,"ok":{"protocol":2,"daemon":"1.0.0","devices":[],"states":{}}} +Reply {"id":2,"ok":{"rev":5,"state":{"available":true,"pending":{},"unread":[],"zoom":{"factor":1.5}}}} +Reply {"id":4,"ok":{"rev":6}} +Reply {"id":"v","ok":{"rev":7,"result":{"id":3}}} +Reply {"id":7,"ok":{}} +Reply {"id":8,"error":{"code":"camera","message":"the camera did not answer","rev":9,"applied":["exposure.mode"],"failed":"exposure.time"}} +Reply {"id":null,"error":{"code":"protocol","message":"the first message must be hello","supported":[2]}} +ErrorBody {"code":"out_of_range","message":"led.brightness: 4 is above the maximum 3"} +Event {"event":"state","device":"sn:EXAMPLE0000001","rev":6,"state":{"available":true,"pending":{"led.brightness":2},"unread":[],"led":{"brightness":1}}} +Event {"event":"devices","devices":[]} +ServerMessage {"event":"devices","devices":[]} +ServerMessage {"id":4,"ok":{"rev":6}} diff --git a/crates/obsbot-proto/tests/schema.rs b/crates/obsbot-proto/tests/schema.rs new file mode 100644 index 0000000..4836487 --- /dev/null +++ b/crates/obsbot-proto/tests/schema.rs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Stale test of `schema/protocol-2.json`. `scripts/regenerate.sh` rewrites the file +//! (`OBSBOT_REGENERATE=1`); without it, any difference fails. + +use obsbot_proto::schema::{protocol_schema, schema_text}; +use std::path::PathBuf; + +fn schema_file() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../schema/protocol-2.json") +} + +#[test] +fn schema_file_is_current() { + let path = schema_file(); + let want = schema_text(); + if std::env::var_os("OBSBOT_REGENERATE").is_some() { + std::fs::write(&path, &want).expect("write schema/protocol-2.json"); + return; + } + let have = std::fs::read_to_string(&path).unwrap_or_default(); + assert!( + have == want, + "schema/protocol-2.json is stale: run scripts/regenerate.sh and commit the result" + ); +} + +#[test] +fn schema_names_every_envelope_and_the_description_format() { + let schema = protocol_schema(); + let defs = schema["$defs"].as_object().expect("$defs"); + for name in [ + "Request", + "Reply", + "Event", + "ErrorBody", + "ErrorCode", + "Device", + "Description", + "Setting", + "Action", + "State", + ] { + assert!(defs.contains_key(name), "the schema lacks {name}"); + } + let codes = serde_json::to_string(&defs["ErrorCode"]).unwrap(); + for code in obsbot_proto::ErrorCode::ALL { + assert!( + codes.contains(&format!("\"{}\"", code.as_str())), + "ErrorCode lacks {}", + code.as_str() + ); + } +} diff --git a/crates/obsbot-proto/tests/transcripts.rs b/crates/obsbot-proto/tests/transcripts.rs new file mode 100644 index 0000000..ae23a24 --- /dev/null +++ b/crates/obsbot-proto/tests/transcripts.rs @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Golden protocol transcripts (`tests/golden/*.jsonl` except `types.jsonl`): `> ` lines go +//! from a client to the daemon, `< ` lines from the daemon to a client, `#` lines are +//! comments. Every line round-trips byte for byte, and the ordering rules of 4.1 that a +//! transcript can show hold in each file. Tracks C and D build against these files. + +use obsbot_proto::*; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +fn transcripts() -> Vec<(String, String)> { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/golden"); + let mut files: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|x| x == "jsonl") && !p.ends_with("types.jsonl")) + .collect(); + files.sort(); + files + .into_iter() + .map(|p| { + ( + p.file_name().unwrap().to_string_lossy().into_owned(), + std::fs::read_to_string(&p).unwrap(), + ) + }) + .collect() +} + +/// The daemon's lines of one transcript, parsed, after checking every line's round trip. +fn parse(name: &str, text: &str) -> Vec { + let mut out = Vec::new(); + for (n, line) in text.lines().enumerate() { + if line.is_empty() || line.starts_with('#') { + continue; + } + let at = format!("{name}:{}", n + 1); + if let Some(json) = line.strip_prefix("> ") { + let req = Request::from_line(json).unwrap_or_else(|e| panic!("{at}: {e}")); + assert_eq!( + serde_json::to_string(&req).unwrap(), + json, + "{at}: not byte-equal" + ); + } else if let Some(json) = line.strip_prefix("< ") { + let msg: ServerMessage = + serde_json::from_str(json).unwrap_or_else(|e| panic!("{at}: {e}")); + assert_eq!( + serde_json::to_string(&msg).unwrap(), + json, + "{at}: not byte-equal" + ); + out.push(msg); + } else { + panic!("{at}: a line starts with '> ', '< ' or '#'"); + } + } + out +} + +#[test] +fn the_three_transcripts_exist() { + let names: Vec = transcripts().into_iter().map(|(n, _)| n).collect(); + assert_eq!(names, ["errors.jsonl", "hotplug.jsonl", "session.jsonl"]); +} + +#[test] +fn every_transcript_line_round_trips_byte_equal() { + for (name, text) in transcripts() { + parse(&name, &text); + } +} + +#[test] +fn the_error_transcript_holds_every_error_code() { + let (_, text) = transcripts() + .into_iter() + .find(|(n, _)| n == "errors.jsonl") + .unwrap(); + let codes: Vec = parse("errors.jsonl", &text) + .into_iter() + .map(|m| match m { + ServerMessage::Reply(Reply { + body: ReplyBody::Error(e), + .. + }) => e.code, + other => panic!("errors.jsonl holds only error replies, not {other:?}"), + }) + .collect(); + assert_eq!(codes, ErrorCode::ALL.to_vec()); +} + +/// 4.1 rule 1 and Events: a rev only goes up per entry; a state event names a listed device; +/// a client forgets an id's rev when a devices event no longer lists it. +#[test] +fn revs_and_device_lists_follow_the_ordering_rules() { + for (name, text) in transcripts() { + let mut listed: Option> = None; + let mut revs: BTreeMap = BTreeMap::new(); + for msg in parse(&name, &text) { + match msg { + ServerMessage::Reply(Reply { + body: ReplyBody::Ok(OkBody::Hello(h)), + .. + }) => { + listed = Some(h.devices.iter().map(|d| d.id.clone()).collect()); + revs = h.states.iter().map(|(id, s)| (id.clone(), s.rev)).collect(); + } + ServerMessage::Event(Event::Devices { devices }) => { + let ids: BTreeSet = devices.iter().map(|d| d.id.clone()).collect(); + revs.retain(|id, _| ids.contains(id)); + listed = Some(ids); + } + ServerMessage::Event(Event::State { device, rev, .. }) => { + if let Some(ids) = &listed { + assert!( + ids.contains(&device), + "{name}: state of {device}, which no list holds" + ); + } + if let Some(old) = revs.get(&device) { + assert!( + rev > *old, + "{name}: rev of {device} went from {old} to {rev}" + ); + } + revs.insert(device, rev); + } + _ => {} + } + } + } +} + +/// 5.5: the hand-over is devices (old entry with replaced_by), devices (without it), state. +#[test] +fn the_hand_over_is_three_events_in_order() { + let (_, text) = transcripts() + .into_iter() + .find(|(n, _)| n == "hotplug.jsonl") + .unwrap(); + let msgs = parse("hotplug.jsonl", &text); + let at = msgs + .iter() + .position(|m| matches!(m, ServerMessage::Event(Event::Devices { devices }) if devices.iter().any(|d| d.replaced_by.is_some()))) + .expect("a devices event with replaced_by"); + let ServerMessage::Event(Event::Devices { devices: first }) = &msgs[at] else { + unreachable!() + }; + let old = first.iter().find(|d| d.replaced_by.is_some()).unwrap(); + assert!(!old.available); + let new_id = old.replaced_by.clone().unwrap(); + assert!(first.iter().any(|d| d.id == new_id && d.available)); + let ServerMessage::Event(Event::Devices { devices: second }) = &msgs[at + 1] else { + panic!("devices event expected") + }; + assert!(second.iter().all(|d| d.id != old.id)); + assert!( + matches!(&msgs[at + 2], ServerMessage::Event(Event::State { device, .. }) if *device == new_id) + ); +} + +/// Public text (spec 8.5): a golden file may name the Tiny 2 Lite's own USB id (`fef9`) and +/// otherwise only synthetic product ids of the fakes' range `fa0x`, never another real model's +/// id. Covers `types.jsonl` too, as `"product":""` and in a name as `3564:`. +#[test] +fn golden_files_name_no_other_real_product_id() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/golden"); + let allowed = |id: &str| id == "fef9" || (id.len() == 4 && id.starts_with("fa0")); + let mut seen = 0; + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + let text = std::fs::read_to_string(&path).unwrap(); + for marker in ["\"product\":\"", "3564:"] { + for rest in text.split(marker).skip(1) { + let id: String = rest.chars().take_while(|c| c.is_ascii_hexdigit()).collect(); + assert!( + allowed(&id), + "{}: product id {id} is neither fef9 nor fa0x", + path.display() + ); + seen += 1; + } + } + } + assert!(seen > 0, "the golden files name product ids"); +} diff --git a/crates/obsbot-proto/tests/types.rs b/crates/obsbot-proto/tests/types.rs new file mode 100644 index 0000000..b731e84 --- /dev/null +++ b/crates/obsbot-proto/tests/types.rs @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Every protocol type round-trips byte for byte against `tests/golden/types.jsonl`, and the +//! additive-change rules of 4.1 rule 7 hold: unknown enum values, types and codes parse. + +use obsbot_proto::*; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::json; + +const GOLDEN: &str = include_str!("golden/types.jsonl"); + +fn round(json: &str) -> T { + let value: T = + serde_json::from_str(json).unwrap_or_else(|e| panic!("does not parse: {e}\n{json}")); + let back = serde_json::to_string(&value).unwrap(); + assert_eq!(back, json, "not byte-equal after a round trip"); + value +} + +#[test] +fn every_golden_line_round_trips_byte_equal() { + let mut seen = std::collections::BTreeSet::new(); + for line in GOLDEN + .lines() + .filter(|l| !l.is_empty() && !l.starts_with('#')) + { + let (name, json) = line.split_once(' ').expect("type name, space, JSON"); + seen.insert(name); + match name { + "RequestId" => drop(round::(json)), + "Support" => drop(round::(json)), + "Unit" => drop(round::(json)), + "UnitSpec" => drop(round::(json)), + "Bound" => drop(round::(json)), + "Asleep" => drop(round::(json)), + "Warning" => drop(round::(json)), + "SettingType" => drop(round::(json)), + "ErrorCode" => drop(round::(json)), + "Requirement" => drop(round::(json)), + "ExtensionUnit" => drop(round::(json)), + "Usb" => drop(round::(json)), + "Setting" => drop(round::(json)), + "Action" => drop(round::(json)), + "Description" => drop(round::(json)), + "Device" => drop(round::(json)), + "State" => drop(round::(json)), + "Patch" => drop(round::(json)), + "Hello" => drop(round::(json)), + "Request" => drop(round::(json)), + "Reply" => drop(round::(json)), + "ErrorBody" => drop(round::(json)), + "Event" => drop(round::(json)), + "ServerMessage" => drop(round::(json)), + other => panic!("unknown type name {other}"), + } + } + assert_eq!(seen.len(), 24, "every type has at least one golden line"); +} + +#[test] +fn additive_values_from_a_newer_daemon_still_parse() { + assert_eq!(round::(r#""beta""#), Support::Other("beta".into())); + assert_eq!( + round::(r#""matrix""#), + SettingType::Other("matrix".into()) + ); + assert_eq!(round::(r#""lux""#), Unit::Other("lux".into())); + assert_eq!( + round::(r#""rate_limited""#), + ErrorCode::Other("rate_limited".into()) + ); + let unknown: ServerMessage = serde_json::from_str(r#"{"event":"battery","level":3}"#).unwrap(); + assert_eq!( + unknown, + ServerMessage::Event(Event::Unknown), + "unknown event types are ignorable" + ); + let reply: Reply = + serde_json::from_str(r#"{"id":4,"ok":{"rev":6,"extra":true},"trace":"x"}"#).unwrap(); + assert_eq!( + reply.body, + ReplyBody::Ok(OkBody::Rev(RevOk { + rev: 6, + result: None + })), + "unknown fields are ignored" + ); +} + +#[test] +fn requests_parse_into_their_verbs() { + // Review Focus 4: a newer client's extra fields are ignored, not refused. + let extra = Request::from_line(r#"{"id":9,"device":"d","get":null,"trace":"abc"}"#).unwrap(); + assert_eq!( + extra.verb, + Verb::Get { + device: "d".into(), + paths: None + } + ); + let get = Request::from_line(r#"{"id":2,"device":"sn:EXAMPLE0000001","get":null}"#).unwrap(); + assert_eq!( + get.verb, + Verb::Get { + device: "sn:EXAMPLE0000001".into(), + paths: None + }, + "get:null is the full state" + ); + let stop = Request::from_line(r#"{"id":6,"device":"d","do":"gimbal.stop"}"#).unwrap(); + assert_eq!( + stop.verb, + Verb::Do { + device: "d".into(), + action: "gimbal.stop".into(), + params: Params::new() + }, + "with defaults to {{}}" + ); + let hello: Request = + serde_json::from_str(r#"{"id":1,"hello":{"protocol":2,"client":"x"}}"#).unwrap(); + assert_eq!( + hello.verb, + Verb::Hello(Hello { + protocol: 2, + client: "x".into(), + subscribe: true, + live: vec![] + }), + "subscribe defaults to true on the wire" + ); + assert_eq!( + serde_json::to_value(Hello::new("cli 1.0")).unwrap(), + json!({"protocol":2,"client":"cli 1.0","subscribe":false,"live":[]}) + ); +} + +#[test] +fn malformed_requests_are_refused_with_their_id_when_it_is_usable() { + let cases = [ + (r#"{"id":1}"#, Some(RequestId::Int(1)), "no verb"), + ( + r#"{"id":1,"device":"d","get":null,"set":{}}"#, + Some(RequestId::Int(1)), + "more than one verb", + ), + (r#"{"device":"d","get":null}"#, None, "no id"), + ( + r#"{"id":"x","get":null}"#, + Some(RequestId::Str("x".into())), + "names no device", + ), + ( + r#"{"id":2,"device":"d","set":[1]}"#, + Some(RequestId::Int(2)), + "malformed", + ), + (r#"{"id":1.5,"live":[]}"#, None, "malformed"), + ("not json", None, "not JSON"), + (r#"[1,2]"#, None, "malformed"), + ]; + for (line, id, part) in cases { + let err = Request::from_line(line).unwrap_err(); + assert_eq!(err.id, id, "{line}"); + assert!(err.message.contains(part), "{line}: {}", err.message); + } +} diff --git a/crates/obsbot-proto/tests/validate.rs b/crates/obsbot-proto/tests/validate.rs new file mode 100644 index 0000000..7f563ce --- /dev/null +++ b/crates/obsbot-proto/tests/validate.rs @@ -0,0 +1,489 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Table tests of the validator. Each row names the rule of the spec it pins. + +use obsbot_proto::validate::{ + Fit, check_description, check_value, validate_do, validate_get, validate_set, +}; +use obsbot_proto::{ + Action, Asleep, Description, ErrorCode, Patch, Setting, SettingType, State, Unit, Warning, +}; +use serde_json::{Value, json}; + +/// A small device: every setting type, a `requires`, an `asleep: ignored` leaf, a read-only +/// leaf, a destructive action and an action with an optional parameter. +fn desc() -> Description { + let w = |s: Setting| s.asleep(Asleep::Unknown).settle_ms(3000); + Description::default() + .setting("asleep", Setting::boolean("Asleep").readonly()) + .setting( + "led.brightness", + w(Setting::number("LED brightness", 0.0, 3.0).step(1.0)), + ) + .setting( + "zoom.factor", + Setting::number("Zoom", 1.0, 4.0) + .precision(0.01) + .asleep(Asleep::Ignored) + .settle_ms(3000), + ) + .setting( + "zoom.field_of_view", + w( + Setting::enumeration("Field of view", &["wide", "medium", "custom"]) + .writable_only(&["wide", "medium"]), + ), + ) + .setting( + "exposure.mode", + w(Setting::enumeration("Exposure mode", &["auto", "manual"])), + ) + .setting( + "exposure.time", + w(Setting::number("Exposure time", 100.0, 250_000.0).unit(Unit::Microseconds)) + .requires("exposure.mode", json!("manual")), + ) + .setting( + "zone.area.pan", + w(Setting::range("Zone pan", -150.0, 150.0).unit(Unit::Deg)), + ) + .setting( + "zone.return", + Setting::composite( + SettingType::Pose, + "Zone return", + &[("pan", -150.0), ("tilt", -70.0), ("zoom", 1.0)], + &[("pan", 150.0), ("tilt", 90.0), ("zoom", 4.0)], + ) + .readonly(), + ) + .setting("gimbal.invert", w(Setting::boolean("Invert"))) + .setting( + "presets", + Setting::list( + "Presets", + &[ + ("slot", Setting::number("Slot", 0.0, 2.0)), + ("name", Setting::string("Name", 32, true)), + ], + ), + ) + .action( + "preset.save", + Action::new("Save a preset", Asleep::Ignored) + .param("slot", Setting::number("Slot", 0.0, 2.0).step(1.0)) + .param("name", Setting::string("Name", 32, true).optional()), + ) + .action( + "factory_reset", + Action::new("Factory reset", Asleep::Unknown) + .param("confirm", Setting::boolean("Confirm")) + .warning(Warning::Destructive) + .warning(Warning::Restart), + ) + .limit("presets", 3) +} + +fn awake() -> State { + let mut s = State::new(true); + s.set("asleep", json!(false)); + s.set("exposure.mode", json!("auto")); + s +} + +fn patch(v: Value) -> Patch { + Patch(v.as_object().expect("a patch is an object").clone()) +} + +fn code_of_set(state: &State, p: Value) -> Option { + validate_set(&desc(), state, &patch(p)) + .err() + .map(|r| r.code) +} + +#[test] +fn the_test_description_is_consistent() { + assert_eq!(check_description(&desc()), Vec::::new()); +} + +#[test] +fn set_table() { + use ErrorCode::*; + let rows: Vec<(&str, Value, Option)> = vec![ + ("valid, dotted", json!({"led.brightness": 2}), None), + ("valid, nested", json!({"led": {"brightness": 2}}), None), + ( + "valid, float that is a whole number", + json!({"led.brightness": 2.0}), + None, + ), + ( + "valid range", + json!({"zone.area.pan": {"min": -20, "max": 20}}), + None, + ), + ( + "valid range, nested and dotted mixed", + json!({"zone": {"area.pan": {"min": -20, "max": 20}}}), + None, + ), + ( + "wrong type: string for a number", + json!({"led.brightness": "2"}), + Some(Invalid), + ), + ( + "wrong type: number for a bool", + json!({"gimbal.invert": 1}), + Some(Invalid), + ), + ( + "wrong type: null", + json!({"gimbal.invert": null}), + Some(Invalid), + ), + ( + "wrong type: range without max", + json!({"zone.area.pan": {"min": -20}}), + Some(Invalid), + ), + ( + "wrong type: range with an extra field", + json!({"zone.area.pan": {"min": -20, "max": 20, "mid": 0}}), + Some(Invalid), + ), + ( + "read-only leaf (4.2)", + json!({"zone.return": {"pan": 0, "tilt": 0, "zoom": 1}}), + Some(Invalid), + ), + ("read-only list", json!({"presets": []}), Some(Invalid)), + ( + "a group is not a leaf (4.2 Leaves)", + json!({"zoom": 2}), + Some(Invalid), + ), + ( + "a leaf named twice", + json!({"zoom": {"factor": 2}, "zoom.factor": 3}), + Some(Invalid), + ), + ("empty patch", json!({}), Some(Invalid)), + ( + "unknown path, a typo", + json!({"led.brigthness": 2}), + Some(Unsupported), + ), + ( + "a path of another model, not on this device", + json!({"tracking.mode": "static"}), + Some(Unsupported), + ), + ("above max", json!({"led.brightness": 4}), Some(OutOfRange)), + ("below min", json!({"zoom.factor": 0.5}), Some(OutOfRange)), + ( + "not on the step", + json!({"led.brightness": 1.5}), + Some(OutOfRange), + ), + ( + "enum value shown but not writable", + json!({"zoom.field_of_view": "custom"}), + Some(OutOfRange), + ), + ( + "enum value unknown", + json!({"zoom.field_of_view": "narrow"}), + Some(OutOfRange), + ), + ( + "range component out of bounds", + json!({"zone.area.pan": {"min": -160, "max": 20}}), + Some(OutOfRange), + ), + ( + "range min above max", + json!({"zone.area.pan": {"min": 30, "max": 20}}), + Some(OutOfRange), + ), + ( + "requires not met: time alone while auto (4.2)", + json!({"exposure.time": 20000}), + Some(Conflict), + ), + ( + "requires met by the same patch (4.2)", + json!({"exposure": {"mode": "manual", "time": 20000}}), + None, + ), + ( + "invalid wins over unsupported", + json!({"led.brightness": "x", "nope": 1}), + Some(Invalid), + ), + ( + "unsupported wins over out_of_range", + json!({"led.brightness": 9, "nope": 1}), + Some(Unsupported), + ), + ( + "out_of_range wins over conflict", + json!({"led.brightness": 9, "exposure.time": 20000}), + Some(OutOfRange), + ), + // Review Focus 1: float noise from a client's arithmetic is not a step violation. + ( + "float noise on a stepped value", + json!({"led.brightness": 2.000_000_000_1}), + None, + ), + // Review Focus 2: the CLI's `min,max` syntax sent as a JSON array. + ( + "a range as an array", + json!({"zone.area.pan": [-20, 20]}), + Some(Invalid), + ), + // Review Focus 3: malformed paths are refused, never a panic. + ( + "an empty path segment", + json!({"led..brightness": 2}), + Some(Unsupported), + ), + ("a trailing dot", json!({"led.": 2}), Some(Unsupported)), + ("an empty key", json!({"": 2}), Some(Unsupported)), + ]; + for (name, p, want) in rows { + assert_eq!(code_of_set(&awake(), p), want, "row: {name}"); + } +} + +#[test] +fn asleep_ignored_is_refused_only_while_asleep() { + let mut asleep = awake(); + asleep.set("asleep", json!(true)); + assert_eq!( + code_of_set(&asleep, json!({"zoom.factor": 2})), + Some(ErrorCode::Asleep), + "ignored while asleep" + ); + assert_eq!( + code_of_set(&awake(), json!({"zoom.factor": 2})), + None, + "awake: written" + ); + assert_eq!( + code_of_set(&asleep, json!({"led.brightness": 2})), + None, + "asleep: unknown is written (5.7)" + ); + assert_eq!( + code_of_set(&asleep, json!({"zoom.factor": 2, "exposure.time": 300})), + Some(ErrorCode::Conflict), + "conflict wins over asleep" + ); +} + +#[test] +fn a_refusal_names_the_path_at_fault() { + let r = validate_set( + &desc(), + &awake(), + &patch(json!({"led.brightness": 2, "zoom.factor": 9})), + ) + .unwrap_err(); + assert_eq!(r.code, ErrorCode::OutOfRange); + assert_eq!(r.path.as_deref(), Some("zoom.factor")); + assert!(r.message.contains("zoom.factor"), "{}", r.message); +} + +#[test] +fn a_valid_patch_comes_back_as_leaves_in_path_order() { + let leaves = validate_set( + &desc(), + &awake(), + &patch(json!({"zoom": {"factor": 2}, "led.brightness": 1})), + ) + .unwrap(); + assert_eq!( + leaves, + vec![ + ("led.brightness".to_string(), json!(1)), + ("zoom.factor".to_string(), json!(2)) + ] + ); +} + +#[test] +fn do_table() { + use ErrorCode::*; + let rows: Vec<(&str, &str, Value, Option)> = vec![ + ("valid", "preset.save", json!({"slot": 1}), None), + ( + "valid with the optional name", + "preset.save", + json!({"slot": 1, "name": "Desk"}), + None, + ), + ( + "unknown action", + "preset.load", + json!({"slot": 1}), + Some(Unsupported), + ), + ("missing parameter", "preset.save", json!({}), Some(Invalid)), + ( + "unknown parameter", + "preset.save", + json!({"slot": 1, "speed": 3}), + Some(Invalid), + ), + ( + "wrong type", + "preset.save", + json!({"slot": "one"}), + Some(Invalid), + ), + ( + "out of range", + "preset.save", + json!({"slot": 3}), + Some(OutOfRange), + ), + ( + "name too long", + "preset.save", + json!({"slot": 0, "name": "x".repeat(33)}), + Some(OutOfRange), + ), + ( + "name not ASCII", + "preset.save", + json!({"slot": 0, "name": "Küche"}), + Some(OutOfRange), + ), + ( + "destructive without confirm (4.2 warning)", + "factory_reset", + json!({}), + Some(Invalid), + ), + ( + "destructive with confirm false", + "factory_reset", + json!({"confirm": false}), + Some(Invalid), + ), + ( + "destructive with confirm true", + "factory_reset", + json!({"confirm": true}), + None, + ), + ]; + for (name, action, params, want) in rows { + let params = params.as_object().unwrap().clone(); + let got = validate_do(&desc(), &awake(), action, ¶ms) + .err() + .map(|r| r.code); + assert_eq!(got, want, "row: {name}"); + } + let mut asleep = awake(); + asleep.set("asleep", json!(true)); + let params = json!({"slot": 1}).as_object().unwrap().clone(); + assert_eq!( + validate_do(&desc(), &asleep, "preset.save", ¶ms) + .err() + .map(|r| r.code), + Some(ErrorCode::Asleep) + ); +} + +#[test] +fn get_accepts_leaves_groups_and_daemon_fields_only() { + let ok = |paths: &[&str]| { + validate_get( + &desc(), + &paths.iter().map(|p| p.to_string()).collect::>(), + ) + }; + assert!( + ok(&[ + "zoom.factor", + "zone", + "zone.area", + "pending", + "available", + "unread" + ]) + .is_ok() + ); + assert_eq!(ok(&["tracking"]).unwrap_err().code, ErrorCode::Unsupported); + assert_eq!( + ok(&["zoom.factorx"]).unwrap_err().code, + ErrorCode::Unsupported + ); + // Review Focus 5: a component of a leaf is not a path of its own. + assert_eq!( + ok(&["zone.area.pan.min"]).unwrap_err().code, + ErrorCode::Unsupported + ); +} + +#[test] +fn state_values_may_be_null_where_nullable_and_raw_where_undecodable() { + let mode = Setting::enumeration("Mode", &["a", "b"]); + assert!( + check_value(&mode, &json!("raw:7"), Fit::State).is_ok(), + "4.2 Undecodable values" + ); + assert!( + check_value(&mode, &json!("raw:7"), Fit::Write).is_err(), + "raw values are never written" + ); + assert!( + check_value(&mode, &Value::Null, Fit::State).is_err(), + "null only where nullable" + ); + assert!(check_value(&mode.clone().nullable(), &Value::Null, Fit::State).is_ok()); +} + +#[test] +fn check_description_finds_broken_descriptions() { + let broken = Description::default() + .setting("Zoom.Factor", Setting::number("Zoom", 4.0, 1.0)) + .setting( + "zone.area", + Setting::boolean("Zone") + .asleep(Asleep::Unknown) + .settle_ms(3000), + ) + .setting("zone.area.pan", Setting::range("Pan", -150.0, 150.0)) + .setting( + "mode", + Setting::enumeration("Mode", &["a"]) + .writable_only(&["b"]) + .asleep(Asleep::Unknown) + .settle_ms(3000), + ) + .action( + "factory_reset", + Action::new("Reset", Asleep::Unknown).warning(Warning::Destructive), + ) + .limit("zone.area", 3); + let problems = check_description(&broken).join("\n"); + for expected in [ + "Zoom.Factor: not lower-case", + "Zoom.Factor: min is above max", + "Zoom.Factor: a writable leaf needs asleep", + "zone.area: a leaf that is also a group", + "zone.area.pan: a writable leaf needs asleep", + "mode: writable holds a value that values does not", + "factory_reset: a destructive action needs the bool parameter confirm", + "limits.zone.area: not a list setting", + ] { + assert!( + problems.contains(expected), + "missing {expected:?} in:\n{problems}" + ); + } +} diff --git a/crates/obsbot4linux/Cargo.toml b/crates/obsbot4linux/Cargo.toml index a5e8385..1413c56 100644 --- a/crates/obsbot4linux/Cargo.toml +++ b/crates/obsbot4linux/Cargo.toml @@ -16,11 +16,7 @@ name = "obsbot4linux" path = "src/main.rs" [dependencies] -obsbot-device.workspace = true -clap.workspace = true -nix.workspace = true -serde.workspace = true -serde_json.workspace = true +obsbot-proto.workspace = true [package.metadata.deb] maintainer = "Thomas Kroll " diff --git a/crates/obsbot4linux/src/cli.rs b/crates/obsbot4linux/src/cli.rs deleted file mode 100644 index 4d458e1..0000000 --- a/crates/obsbot4linux/src/cli.rs +++ /dev/null @@ -1,1218 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -//! Argument parsing and output. Every command prints text, or JSON with `--json`. - -use std::io::Write; - -use clap::{Parser, Subcommand, ValueEnum}; -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::models::tiny2lite::commands::*; -use obsbot_device::models::tiny2lite::presets::{Pose, StartupAi}; -use serde_json::{Value, json}; - -#[derive(Parser)] -#[command( - name = "obsbot4linux", - version, - about = "Control the OBSBOT Tiny 2 Lite" -)] -pub struct Cli { - /// Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - #[arg(long, global = true)] - pub json: bool, - /// Do not use a running daemon; talk to the camera directly - #[arg(long, global = true)] - direct: bool, - #[command(subcommand)] - pub command: Cmd, -} - -#[derive(Subcommand)] -pub enum HandCmd { - /// Track the right hand - Right, - /// Track the left hand - Left, - /// Take the current camera pose as the position the camera returns to after the hand leaves - ReturnHere, - /// Move the camera to the hand-tracking return position - ReturnGo, - /// Clear the hand-tracking return position - ReturnReset, - /// Area the hand is tracked in, in degrees. The camera's default: --pan -45 45 --tilt -30 30 - Area { - /// Pan range, MIN MAX, positive = camera left (-150..150) - #[arg(long, num_args = 2, value_names = ["MIN", "MAX"], allow_negative_numbers = true, required = true)] - pan: Vec, - /// Tilt range, MIN MAX, positive = up (-70..90) - #[arg(long, num_args = 2, value_names = ["MIN", "MAX"], allow_negative_numbers = true, required = true)] - tilt: Vec, - }, - /// Set the hand tracking area back to the camera's default - AreaReset, - /// Whether the hand tracker may pan (off: the pan axis stays where it is) - Pan { state: OnOff }, - /// Whether the hand tracker may tilt (off: the tilt axis stays where it is) - Tilt { state: OnOff }, - /// Seconds the hand may stay outside the area before the camera returns to the return position - ReturnAfter { seconds: f32 }, - /// Whether the hand tracker may move the gimbal at all (off stops it; the camera does not - /// keep this setting) - Gimbal { state: OnOff }, -} - -#[derive(Subcommand)] -pub enum LedCmd { - /// Let the status LED blink (on) or burn steadily (off). The camera does not report it - Blink { state: OnOff }, -} - -#[derive(Subcommand)] -pub enum WakeCmd { - /// Stored wake-up zoom, factor 1.0-4.0 (the camera stores 0-100 percent). Effect unknown on - /// the Tiny 2 Lite: no change at power-on or on waking from sleep - Zoom { factor: f32 }, - /// Tracking mode the camera starts in at power-on, not on waking from sleep (the current mode - /// stays) - Mode { mode: AiMode }, -} - -#[derive(Subcommand)] -pub enum GimbalCmd { - /// Stop all gimbal axes (also a `gimbal rate` still running) - Stop, - /// Turn the gimbal so that a point of the picture moves to its centre: x and y from 0.0 to - /// 1.0, from the top left (switches tracking to static first) - #[command(hide = true)] - Center { x: f32, y: f32 }, - /// Keep turning at a speed in degrees per second (up to 178) UNTIL `gimbal stop` or a speed - /// of 0, also when the client is gone; like the joystick of OBSBOT Center (switches - /// tracking to static first). An axis you leave out is stopped: `--pan 5` alone also - /// stops a running tilt turn - Rate { - /// Positive turns the camera to its left - #[arg(long, allow_negative_numbers = true)] - pan: Option, - /// Positive tilts the camera up - #[arg(long, allow_negative_numbers = true)] - tilt: Option, - }, -} - -#[derive(Subcommand)] -pub enum ZoomCmd { - /// Stop a zoom in progress - Stop, -} - -#[derive(Subcommand)] -pub enum FramingCmd { - /// Auto zoom of the human tracking on or off - AutoZoom { state: OnOff }, - /// Size of the auto zoom framing - Size { size: FramingSize }, - /// How fast the auto zoom follows - ZoomSpeed { speed: AutoZoomSpeed }, - /// Shift the framing by a fraction of the frame, -0.16 to 0.16 - Offset { - #[arg(long, allow_negative_numbers = true)] - x: Option, - #[arg(long, allow_negative_numbers = true)] - y: Option, - }, - /// Which part of the body the framing follows - Part { part: PartMode }, -} - -#[derive(Clone, Copy, ValueEnum)] -pub enum OnOff { - On, - Off, -} - -impl OnOff { - pub fn get(self) -> bool { - matches!(self, Self::On) - } -} - -#[derive(Subcommand)] -pub enum Cmd { - /// Run the daemon: owns the camera, serves the socket, broadcasts state changes - Daemon, - /// Stream state events as JSON lines and forward JSON requests from stdin (for widgets) - Watch, - /// Ask the daemon to poll the gimbal pose for this connection (a `watch` request; the daemon - /// polls it every second only while a connection wants it). Not usable as a one-off call - LivePose { state: OnOff }, - /// Exit 0 and print the model (--json: model and video device) when a supported camera is - /// connected, exit 1 with no output when not. Enumeration only: no daemon, no camera command - Detect, - /// Show all settings (the camera's status block, tracking readback, zoom, image controls, remembered state) - Status, - /// Put the camera to sleep - Sleep, - /// Wake the camera, or set the stored wake-up zoom (`wake zoom`) or the tracking mode at - /// power-on (`wake mode`) - Wake { - #[command(subcommand)] - setting: Option, - }, - /// AI tracking mode - Tracking { mode: AiMode }, - /// Tracking type: standard, sport (fixed fast gain) or headroom (more room above the head) - Speed { speed: TrackingSpeed }, - /// Fine tracking speed, from lazy to very-fast, or auto. Ignored while `speed sport` is set - /// or the part mode is not normal - SpeedMode { mode: SpeedMode }, - /// Framing options of the human tracking; an AI mode change overwrites them - #[command(subcommand)] - Framing(FramingCmd), - /// HDR - Hdr { state: OnOff }, - /// Exposure: automatic metering over the whole picture (global) or on faces (face), or manual, - /// optionally with the shutter time and gain (`image gain` sets the gain too; `image exposure_time` - /// is refused unless the exposure is already manual) - Exposure { - mode: ExposureArg, - /// Manual only: shutter time in 100 us units, 1-2500 (330 is one frame at 30 fps); the - /// camera uses the step at or below it - #[arg(long)] - time: Option, - /// Manual only: gain 1-64 (ISO is gain x 100) - #[arg(long)] - gain: Option, - }, - /// Zoom factor 1.0-4.0; with --speed the camera zooms smoothly instead of jumping - #[command(args_conflicts_with_subcommands = true)] - Zoom { - #[arg(allow_negative_numbers = true)] - factor: Option, - /// Zoom speed (OBSBOT Center uses 30; the auto zoom 10, 30, 50) - #[arg(long, requires = "factor")] - speed: Option, - #[command(subcommand)] - stop: Option, - }, - /// Move the gimbal to an absolute pose in degrees (switches tracking to static first), or - /// `gimbal stop|rate` - #[command(args_conflicts_with_subcommands = true)] - Gimbal { - /// Positive turns the camera to its left - #[arg(long, allow_negative_numbers = true)] - pan: Option, - /// Positive tilts the camera up - #[arg(long, allow_negative_numbers = true)] - tilt: Option, - /// Take as long as this many degrees per second (1-180) need instead of going as fast - /// as possible - #[arg(long)] - speed: Option, - #[command(subcommand)] - action: Option, - }, - /// Field of view (ignored by the camera while it sleeps) - Fov { fov: FieldOfView }, - /// Status LED: brightness 0 (off) to 3, or `led blink on|off` - #[command(args_conflicts_with_subcommands = true)] - Led { - brightness: Option, - #[command(subcommand)] - blink: Option, - }, - /// Mirror the image - Mirror { state: OnOff }, - /// Invert view and gimbal (reverses the gimbal's pan direction) - Invert { state: OnOff }, - /// Auto-sleep: "off" or a time in seconds - AutoSleep { time: String }, - /// Picture shown while the camera sleeps - SleepBackground { background: SleepBackground }, - /// Mirror the sleep background - SleepBackgroundMirror { state: OnOff }, - /// Microphone settings - #[command(subcommand)] - Audio(Audio), - /// Gesture control - #[command(subcommand)] - Gesture(GestureCmd), - /// Hand tracking: which hand, return position, area, axes - #[command(subcommand)] - Hand(HandCmd), - /// Zone tracking - #[command(subcommand)] - Zone(ZoneCmd), - /// Focus: autofocus mode, continuous autofocus, manual focus - #[command(subcommand)] - Focus(FocusCmd), - /// Initial state: the pose the camera moves to when it powers on - #[command(subcommand)] - Startup(StartupCmd), - /// UVC image control, e.g. "image brightness 128" (names are in "status") - Image { - control: String, - #[arg(allow_negative_numbers = true)] - value: i32, - }, - /// Presets stored in the camera (slots 0-2) - #[command(subcommand)] - Preset(PresetCmd), - /// Reset the camera to factory settings (it restarts and deletes its presets) - FactoryReset { - #[arg(long)] - yes: bool, - }, -} - -#[derive(Clone, Copy, Debug, ValueEnum)] -pub enum ExposureArg { - Global, - Face, - Manual, -} - -#[derive(Subcommand)] -pub enum Audio { - /// Microphone noise reduction - Noise { level: NoiseReduction }, - /// Automatic gain - Gain { state: OnOff }, - /// Pickup distance - Distance { distance: PickupDistance }, - /// Microphone volume 0-100 (below 50 reads as near, 50-84 standard, from 85 far) - Volume { - #[arg(value_parser = clap::value_parser!(u8).range(0..=100))] - volume: u8, - }, - /// Keep the microphone on while the camera sleeps - SleepMic { state: OnOff }, - /// Microphone on/off; every change makes the camera restart (about 18 s) - Mic { state: OnOff }, -} - -#[derive(Subcommand)] -pub enum GestureCmd { - /// Switch one gesture on or off - Set { gesture: Gesture, state: OnOff }, - /// Zoom ratio of the zoom gestures, 1.0-4.0 - ZoomRatio { ratio: f32 }, -} - -#[derive(Subcommand)] -pub enum ZoneCmd { - On, - Off, - /// Automatically follow new targets - Follow { - state: OnOff, - }, - /// Area the zone tracking stays in, in degrees. Full area: --pan -150 150 --tilt -70 90 - Area { - /// Pan range, MIN MAX, positive = camera left (-150..150) - #[arg(long, num_args = 2, value_names = ["MIN", "MAX"], allow_negative_numbers = true, required = true)] - pan: Vec, - /// Tilt range, MIN MAX, positive = up (-70..90) - #[arg(long, num_args = 2, value_names = ["MIN", "MAX"], allow_negative_numbers = true, required = true)] - tilt: Vec, - }, - /// Take the current camera pose as the position the zone tracking returns to - ReturnHere, - /// Move the camera to the zone return position - ReturnGo, - /// Named poses stored in the camera ("zone views"); the camera does not track by them - #[command(subcommand)] - View(ViewCmd), -} - -#[derive(Subcommand)] -pub enum ViewCmd { - /// List the views - List, - /// Store a view under the lowest free id; without --pan/--tilt/--zoom the current pose is used - Save { - name: String, - #[arg(long, allow_negative_numbers = true, requires_all = ["tilt", "zoom"])] - pan: Option, - #[arg(long, allow_negative_numbers = true, requires_all = ["pan", "zoom"])] - tilt: Option, - #[arg(long, requires_all = ["pan", "tilt"])] - zoom: Option, - }, - /// Rename a view (printable ASCII, up to 32 characters) - Rename { id: u32, name: String }, - /// Delete a view - Delete { id: u32 }, - /// Move the camera to a view - Go { id: u32 }, -} - -#[derive(Clone, Copy, ValueEnum)] -pub enum FocusMode { - Face, - Global, -} - -#[derive(Subcommand)] -pub enum FocusCmd { - /// Autofocus mode - Mode { mode: FocusMode }, - /// Continuous autofocus on/off - Auto { state: OnOff }, - /// The camera's own autofocus mode: continuous, single (focus once and hold) or manual - Af { mode: AutofocusMode }, - /// Focus once on a point: x and y from 0.0 to 1.0, from the top left of the picture - Point { x: f32, y: f32 }, - /// Manual focus 0-100 (turns autofocus off) - Set { value: i32 }, -} - -#[derive(Subcommand)] -pub enum StartupCmd { - /// Set the start pose: --pan/--tilt/--zoom, or --preset , or --current. There is - /// no way known to clear it; pan 0, tilt 0, zoom 1 is the neutral start. - Set { - /// Positive turns the camera to its left - #[arg(long, allow_negative_numbers = true, requires_all = ["tilt", "zoom"])] - pan: Option, - /// Positive tilts the camera up - #[arg(long, allow_negative_numbers = true, requires_all = ["pan", "zoom"])] - tilt: Option, - #[arg(long, requires_all = ["pan", "tilt"])] - zoom: Option, - /// Use the pose of a stored preset (slot as in "preset list", 0-2) - #[arg(long, conflicts_with_all = ["pan", "current"])] - preset: Option, - /// Use the current pose (remembered gimbal pose, live zoom) - #[arg(long, conflicts_with = "pan")] - current: bool, - /// AI mode of the initial state (the camera tracks in it again after "startup go" and - /// powers on in it). Kept as it is when not given; "none" clears it, and then - /// "startup go" leaves tracking suspended. Alone, it leaves the pose untouched. - #[arg(long, value_enum)] - ai: Option, - }, - /// Show the start pose and the picture settings of the initial state - Show, - /// Move the camera to the start pose now (the pose it takes at power-on) - Go, -} - -#[derive(Subcommand)] -pub enum PresetCmd { - /// List the presets - List, - /// Save a pose to a slot; without --pan/--tilt/--zoom the current pose is used - /// (the last gimbal pose obsbot4linux sent, else the one V4L2 last set; live zoom) - Save { - slot: u8, - #[arg(long)] - name: Option, - #[arg(long, allow_negative_numbers = true, requires_all = ["tilt", "zoom"])] - pan: Option, - #[arg(long, allow_negative_numbers = true, requires_all = ["pan", "zoom"])] - tilt: Option, - #[arg(long, requires_all = ["pan", "tilt"])] - zoom: Option, - }, - /// Rename a preset (printable ASCII, up to 32 characters) - Rename { slot: u8, name: String }, - /// Delete a preset - Delete { slot: u8 }, - /// Move the camera to a preset - Recall { slot: u8 }, -} - -fn onoff(b: bool) -> &'static str { - if b { "on" } else { "off" } -} - -fn value_name(v: T) -> Value { - serde_json::to_value(v).unwrap_or(Value::Null) -} - -/// Runs the command and returns (JSON result, text for humans). Setters answer with -/// {"ok","command","value"}; `status` prints the status object itself. -pub fn execute(cmd: Cmd) -> Result<(Value, String)> { - execute_with(cmd, None) -} - -/// Like [`execute`]; the daemon passes the identity it already read, so `status` does not ask -/// the camera for the firmware again. -pub fn execute_with(cmd: Cmd, identity: Option) -> Result<(Value, String)> { - // refuse before opening the camera - if let Cmd::FactoryReset { yes: false } = cmd { - return Err(Error::Invalid( - "factory reset deletes presets and settings; pass --yes".into(), - )); - } - if matches!(cmd, Cmd::Daemon | Cmd::Watch | Cmd::Detect) { - return Err(Error::Invalid( - "daemon, watch and detect are not camera commands".into(), - )); - } - if matches!(cmd, Cmd::LivePose { .. }) { - return Err(Error::Invalid(LIVE_POSE_ONE_OFF.into())); - } - let cam = Camera::open()?; - // (command name, value) of a setter - let done = |name: &str, value: Value| { - let text = match &value { - Value::Null => name.to_string(), - Value::String(s) => format!("{name}: {s}"), - v => format!("{name}: {v}"), - }; - Ok((json!({"ok": true, "command": name, "value": value}), text)) - }; - match cmd { - Cmd::Daemon | Cmd::Watch | Cmd::Detect | Cmd::LivePose { .. } => { - unreachable!("handled above") - } - Cmd::Status => { - let full = value_name(cam.full_status_with(None, identity)?); - // the box is for machines: plain text only says whether the camera sees its target - let mut plain = full.clone(); - if let Some(m) = plain.as_object_mut() { - m.remove("target"); - } - let mut text = flatten(&plain); - match full["target"]["detected"].as_bool() { - Some(true) => text.push_str("\ntarget: detected"), - Some(false) => text.push_str("\ntarget: searching"), - None => {} - } - Ok((full, text)) - } - Cmd::Sleep => cam.sleep(true).and_then(|_| done("sleep", Value::Null)), - Cmd::Wake { setting: None } => cam.sleep(false).and_then(|_| done("wake", Value::Null)), - Cmd::Wake { - setting: Some(WakeCmd::Zoom { factor }), - } => cam - .set_wake_zoom(factor) - .and_then(|percent| done("wake_zoom", json!({"factor": factor, "percent": percent}))), - Cmd::Wake { - setting: Some(WakeCmd::Mode { mode }), - } => cam - .set_default_work_mode(mode) - .and_then(|_| done("wake_mode", value_name(mode))), - Cmd::Tracking { mode } => cam - .set_ai_mode(mode) - .and_then(|_| done("tracking", value_name(mode))), - Cmd::Speed { speed } => cam - .set_tracking_speed(speed) - .and_then(|_| done("speed", value_name(speed))), - Cmd::SpeedMode { mode } => cam - .set_speed_mode(mode) - .and_then(|_| done("speed_mode", value_name(mode))), - Cmd::Framing(f) => match f { - FramingCmd::AutoZoom { state } => cam - .set_auto_zoom(state.get()) - .and_then(|_| done("framing_auto_zoom", json!(onoff(state.get())))), - FramingCmd::Size { size } => cam - .set_framing_size(size) - .and_then(|_| done("framing_size", value_name(size))), - FramingCmd::ZoomSpeed { speed } => cam - .set_auto_zoom_speed(speed) - .and_then(|_| done("framing_zoom_speed", value_name(speed))), - FramingCmd::Offset { x, y } => cam - .set_framing_offset(x, y) - .and_then(|_| done("framing_offset", json!({"x": x, "y": y}))), - FramingCmd::Part { part } => cam - .set_part_mode(part) - .and_then(|_| done("framing_part", value_name(part))), - }, - Cmd::Hdr { state } => cam - .set_hdr(state.get()) - .and_then(|_| done("hdr", json!(onoff(state.get())))), - Cmd::Exposure { mode, time, gain } => match mode { - ExposureArg::Manual => cam.set_manual_exposure(time, gain).and_then(|_| { - done( - "exposure", - if time.is_none() && gain.is_none() { - json!("manual") - } else { - json!({"mode": "manual", "time": time, "gain": gain}) - }, - ) - }), - _ if time.is_some() || gain.is_some() => Err(Error::Invalid( - "--time and --gain only go with manual exposure".into(), - )), - ExposureArg::Global => cam - .set_exposure_mode(ExposureMode::Global) - .and_then(|_| done("exposure", json!("global"))), - ExposureArg::Face => cam - .set_exposure_mode(ExposureMode::Face) - .and_then(|_| done("exposure", json!("face"))), - }, - Cmd::Zoom { - stop: Some(ZoomCmd::Stop), - .. - } => cam.zoom_stop().and_then(|_| done("zoom_stop", Value::Null)), - Cmd::Zoom { - factor: Some(factor), - speed: Some(speed), - .. - } => cam - .zoom_to(factor, speed) - .and_then(|_| done("zoom", json!({"factor": factor, "speed": speed}))), - Cmd::Zoom { - factor: Some(factor), - .. - } => cam - .set_zoom(factor) - .and_then(|_| done("zoom", json!(factor))), - Cmd::Zoom { .. } => Err(Error::Invalid( - "give a zoom factor (with --speed to zoom smoothly), or `stop`".into(), - )), - Cmd::Gimbal { - action: Some(action), - .. - } => match action { - GimbalCmd::Stop => cam - .gimbal_stop() - .and_then(|_| done("gimbal_stop", Value::Null)), - GimbalCmd::Center { x, y } => cam - .gimbal_center(x, y) - .and_then(|_| done("gimbal_center", json!({"x": x, "y": y}))), - GimbalCmd::Rate { pan, tilt } => { - if pan.is_none() && tilt.is_none() { - return Err(Error::Invalid("give --pan and/or --tilt".into())); - } - cam.gimbal_rate(pan.unwrap_or(0.0), tilt.unwrap_or(0.0)) - .and_then(|_| done("gimbal_rate", json!({"pan": pan, "tilt": tilt}))) - } - }, - Cmd::Gimbal { - pan, tilt, speed, .. - } => cam - .gimbal(pan, tilt, speed) - .and_then(|_| done("gimbal", json!({"pan": pan, "tilt": tilt, "speed": speed}))), - Cmd::Fov { fov } => cam - .set_field_of_view(fov) - .and_then(|_| done("fov", value_name(fov))), - Cmd::Led { - blink: Some(LedCmd::Blink { state }), - .. - } => cam - .set_led_blink(state.get()) - .and_then(|_| done("led_blink", json!(onoff(state.get())))), - Cmd::Led { - brightness: Some(brightness), - .. - } => cam - .set_led(brightness) - .and_then(|_| done("led", json!(brightness))), - Cmd::Led { .. } => Err(Error::Invalid( - "give a brightness 0-3, or `blink on|off`".into(), - )), - Cmd::Mirror { state } => cam - .set_mirror(state.get()) - .and_then(|_| done("mirror", json!(onoff(state.get())))), - Cmd::Invert { state } => cam - .set_invert(state.get()) - .and_then(|_| done("invert", json!(onoff(state.get())))), - Cmd::AutoSleep { time } => { - let seconds = if time == "off" { - None - } else { - Some( - time.parse::() - .map_err(|_| Error::Invalid("auto-sleep is \"off\" or seconds".into()))?, - ) - }; - cam.set_auto_sleep(seconds) - .and_then(|_| done("auto_sleep", json!(seconds))) - } - Cmd::SleepBackground { background } => cam - .set_sleep_background(background) - .and_then(|_| done("sleep_background", value_name(background))), - Cmd::SleepBackgroundMirror { state } => cam - .set_sleep_background_mirror(state.get()) - .and_then(|_| done("sleep_background_mirror", json!(onoff(state.get())))), - Cmd::Audio(a) => match a { - Audio::Noise { level } => cam - .set_noise_reduction(level) - .and_then(|_| done("audio_noise", value_name(level))), - Audio::Gain { state } => cam - .set_auto_gain(state.get()) - .and_then(|_| done("audio_gain", json!(onoff(state.get())))), - Audio::Distance { distance } => cam - .set_pickup_distance(distance) - .and_then(|_| done("audio_distance", value_name(distance))), - Audio::Volume { volume } => cam - .set_audio_volume(volume) - .and_then(|_| done("audio_volume", json!(volume))), - Audio::SleepMic { state } => cam - .set_mic_in_sleep(state.get()) - .and_then(|_| done("audio_sleep_mic", json!(onoff(state.get())))), - Audio::Mic { state } => cam - .set_microphone(state.get()) - .and_then(|_| done("audio_mic", json!(onoff(state.get())))), - }, - Cmd::Gesture(g) => match g { - GestureCmd::Set { gesture, state } => { - cam.set_gesture(gesture, state.get()).and_then(|_| { - done( - "gesture", - json!({"gesture": gesture, "state": onoff(state.get())}), - ) - }) - } - GestureCmd::ZoomRatio { ratio } => cam - .set_gesture_zoom_ratio(ratio) - .and_then(|_| done("gesture_zoom_ratio", json!(ratio))), - }, - Cmd::Hand(h) => match h { - HandCmd::Right | HandCmd::Left => { - let side = if matches!(h, HandCmd::Left) { - HandSide::Left - } else { - HandSide::Right - }; - cam.set_hand_side(side) - .and_then(|_| done("hand", value_name(side))) - } - HandCmd::ReturnHere => cam - .set_hand_return_here() - .and_then(|pose| done("hand_return", json!({"pose": pose}))), - HandCmd::ReturnGo => cam - .hand_return_go() - .and_then(|_| done("hand_return_go", Value::Null)), - HandCmd::ReturnReset => cam - .hand_return_reset() - .and_then(|_| done("hand_return_reset", Value::Null)), - HandCmd::Area { pan, tilt } => cam - .set_hand_area((pan[0], pan[1]), (tilt[0], tilt[1])) - .and_then(|_| done("hand_area", json!({ "pan": pan, "tilt": tilt }))), - HandCmd::AreaReset => cam - .hand_area_reset() - .and_then(|_| done("hand_area_reset", Value::Null)), - HandCmd::Pan { state } => cam - .set_hand_pan(state.get()) - .and_then(|_| done("hand_pan", json!(onoff(state.get())))), - HandCmd::Tilt { state } => cam - .set_hand_tilt(state.get()) - .and_then(|_| done("hand_tilt", json!(onoff(state.get())))), - HandCmd::ReturnAfter { seconds } => cam - .set_hand_return_after(seconds) - .and_then(|_| done("hand_return_after", json!(seconds))), - HandCmd::Gimbal { state } => cam - .set_hand_gimbal(state.get()) - .and_then(|_| done("hand_gimbal", json!(onoff(state.get())))), - }, - Cmd::Zone(z) => match z { - ZoneCmd::On | ZoneCmd::Off => { - let on = matches!(z, ZoneCmd::On); - cam.set_zone_tracking(on) - .and_then(|_| done("zone", json!(onoff(on)))) - } - ZoneCmd::Follow { state } => cam - .set_zone_follow(state.get()) - .and_then(|_| done("zone_follow", json!(onoff(state.get())))), - ZoneCmd::Area { pan, tilt } => cam - .set_zone_area((pan[0], pan[1]), (tilt[0], tilt[1])) - .and_then(|_| done("zone_area", json!({ "pan": pan, "tilt": tilt }))), - ZoneCmd::ReturnHere => cam - .set_zone_return_here() - .and_then(|pose| done("zone_return", json!({"pose": pose}))), - ZoneCmd::ReturnGo => cam - .zone_return_go() - .and_then(|_| done("zone_return_go", Value::Null)), - ZoneCmd::View(v) => match v { - ViewCmd::List => { - let list = cam.zone_views()?; - let text = if list.is_empty() { - "no zone views".to_string() - } else { - list.iter() - .map(|v| { - format!( - "view {}: {:?} pan {:.2} tilt {:.2} zoom {:.2}", - v.id, v.name, v.pose.pan, v.pose.tilt, v.pose.zoom - ) - }) - .collect::>() - .join("\n") - }; - Ok(( - json!({"ok": true, "command": "zone_view_list", "value": list}), - text, - )) - } - ViewCmd::Save { - name, - pan, - tilt, - zoom, - } => { - let pose = - pan.zip(tilt) - .zip(zoom) - .map(|((pan, tilt), zoom)| Pose { pan, tilt, zoom }); - let view = cam.save_zone_view(&name, pose)?; - done("zone_view_save", json!(view)) - } - ViewCmd::Rename { id, name } => cam - .rename_zone_view(id, &name) - .and_then(|_| done("zone_view_rename", json!({"id": id, "name": name}))), - ViewCmd::Delete { id } => cam - .delete_zone_view(id) - .and_then(|_| done("zone_view_delete", json!({"id": id}))), - ViewCmd::Go { id } => cam - .zone_view_go(id) - .and_then(|_| done("zone_view_go", json!({"id": id}))), - }, - }, - Cmd::Focus(f) => match f { - FocusCmd::Mode { mode } => { - let face = matches!(mode, FocusMode::Face); - cam.set_autofocus_mode(face) - .and_then(|_| done("focus_mode", json!(if face { "face" } else { "global" }))) - } - FocusCmd::Auto { state } => cam - .set_autofocus(state.get()) - .and_then(|_| done("focus_auto", json!(onoff(state.get())))), - FocusCmd::Af { mode } => cam - .set_autofocus_select(mode) - .and_then(|_| done("focus_af", value_name(mode))), - FocusCmd::Point { x, y } => cam - .focus_point(x, y) - .and_then(|_| done("focus_point", json!({"x": x, "y": y}))), - FocusCmd::Set { value } => cam - .set_focus(value) - .and_then(|_| done("focus", json!(value))), - }, - Cmd::Startup(StartupCmd::Set { - pan, - tilt, - zoom, - preset, - current, - ai, - }) => { - let pose = match (pan.zip(tilt).zip(zoom), preset, current) { - (Some(((pan, tilt), zoom)), None, false) => { - cam.set_startup(Pose { pan, tilt, zoom }, None, ai)?; - Some(Pose { pan, tilt, zoom }) - } - (None, Some(slot), false) => Some(cam.set_startup_preset(slot, ai)?), - (None, None, true) => Some(cam.set_startup_current(ai)?), - (None, None, false) => match ai { - Some(ai) => { - cam.set_startup_ai(ai)?; - None - } - None => return Err(Error::Invalid(NEED_STARTUP_ARGS.into())), - }, - _ => return Err(Error::Invalid(NEED_STARTUP_ARGS.into())), - }; - done( - "startup", - json!({"pose": pose, "preset": preset, "ai": ai.and_then(|a| a.to_possible_value()).map(|v| v.get_name().to_string())}), - ) - } - Cmd::Startup(StartupCmd::Show) => { - // the pose is the point of the command: a refused actions query leaves them null - let pose = cam.startup()?; - let actions = cam.startup_actions().ok(); - let value = json!({"pose": pose, "actions": actions}); - let text = flatten(&value); - Ok(( - json!({"ok": true, "command": "startup_show", "value": value}), - text, - )) - } - Cmd::Startup(StartupCmd::Go) => cam - .startup_go() - .and_then(|_| done("startup_go", Value::Null)), - Cmd::Image { control, value } => cam - .set_image(&control, value) - .and_then(|_| done("image", json!({"control": control, "value": value}))), - Cmd::Preset(p) => match p { - PresetCmd::List => { - let list = cam.presets()?; - let text = if list.is_empty() { - "no presets".to_string() - } else { - list.iter() - .map(|p| { - format!( - "slot {}: {:?} pan {:.2} tilt {:.2} zoom {:.2}", - p.slot, p.name, p.pose.pan, p.pose.tilt, p.pose.zoom - ) - }) - .collect::>() - .join("\n") - }; - Ok(( - json!({"ok": true, "command": "preset_list", "value": list}), - text, - )) - } - PresetCmd::Save { - slot, - name, - pan, - tilt, - zoom, - } => { - let pose = - pan.zip(tilt) - .zip(zoom) - .map(|((pan, tilt), zoom)| Pose { pan, tilt, zoom }); - let saved = cam.save_preset(slot, name.as_deref(), pose)?; - done("preset_save", json!({"slot": slot, "pose": saved})) - } - PresetCmd::Rename { slot, name } => cam - .rename_preset(slot, &name) - .and_then(|_| done("preset_rename", json!({"slot": slot, "name": name}))), - PresetCmd::Delete { slot } => cam - .delete_preset(slot) - .and_then(|_| done("preset_delete", json!({"slot": slot}))), - PresetCmd::Recall { slot } => cam - .recall_preset(slot) - .and_then(|_| done("preset_recall", json!({"slot": slot}))), - }, - Cmd::FactoryReset { .. } => cam - .factory_reset() - .and_then(|_| done("factory_reset", Value::Null)), - } -} - -/// Text view of nested JSON: one "path: value" line per leaf. -const NEED_STARTUP_ARGS: &str = "give --pan/--tilt/--zoom, or --preset, or --current, or --ai"; - -fn flatten(v: &Value) -> String { - fn walk(prefix: &str, v: &Value, out: &mut Vec) { - match v { - Value::Object(m) => m.iter().for_each(|(k, v)| { - walk( - &if prefix.is_empty() { - k.clone() - } else { - format!("{prefix}.{k}") - }, - v, - out, - ) - }), - Value::Array(a) if a.iter().all(Value::is_string) => out.push(format!( - "{prefix}: {}", - a.iter() - .filter_map(Value::as_str) - .collect::>() - .join(", ") - )), - Value::String(s) => out.push(format!("{prefix}: {s}")), - v => out.push(format!("{prefix}: {v}")), - } - } - let mut out = Vec::new(); - walk("", v, &mut out); - out.join("\n") -} - -pub fn print_ok(json: bool, result: &Value, text: &str) -> i32 { - // a closed pipe (`obsbot4linux status | head`) is not an error - let _ = writeln!( - std::io::stdout(), - "{}", - if json { - result.to_string() - } else { - text.to_string() - } - ); - 0 -} - -pub fn print_error(json: bool, message: &str) -> i32 { - if json { - let _ = writeln!( - std::io::stdout(), - "{}", - json!({"ok": false, "error": message}) - ); - } else { - eprintln!("obsbot4linux: {message}"); - } - 1 -} - -const LIVE_POSE_ONE_OFF: &str = "live-pose applies to one connection of the daemon: send it as a request over `obsbot4linux watch` (it ends when that connection closes), not as a one-off call"; - -pub fn run() -> i32 { - let cli = Cli::parse(); - match cli.command { - // the daemon would accept it and forget it with this process's connection - Cmd::LivePose { .. } => return print_error(cli.json, LIVE_POSE_ONE_OFF), - Cmd::Daemon => return report(cli.json, crate::daemon::run()), - Cmd::Watch => return report(cli.json, crate::client::watch()), - Cmd::Detect => return detect(cli.json), - _ => {} - } - if !cli.direct { - let args = std::env::args() - .skip(1) - .filter(|a| a != "--direct") - .collect(); - if let Some(code) = crate::client::forward(&cli, args) { - return code; - } - } - match execute(cli.command) { - Ok((json, text)) => print_ok(cli.json, &json, &text), - Err(e) => print_error(cli.json, &e.to_string()), - } -} - -/// `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::enumeration::detect() else { - return 1; - }; - let (value, text) = detect_output(&found); - print_ok(json, &value, &text) -} - -fn detect_output(found: &Detected) -> (Value, String) { - let device = found.device.to_string_lossy(); - ( - json!({"model": found.model.name, "device": device}), - found.model.name.to_string(), - ) -} - -fn report(json: bool, result: Result<()>) -> i32 { - match result { - Ok(()) => 0, - Err(e) => print_error(json, &e.to_string()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use obsbot_device::registry::MODELS; - - fn parse(args: &str) -> std::result::Result { - Cli::try_parse_from(std::iter::once("obsbot4linux").chain(args.split(' '))) - .map(|c| c.command) - } - - #[test] - fn led_keeps_its_plain_brightness_and_gains_blink() { - // `led 2` is what clients send today; it must not turn into a subcommand error - assert!(matches!( - parse("led 2"), - Ok(Cmd::Led { - brightness: Some(2), - blink: None - }) - )); - assert!(matches!( - parse("led blink on"), - Ok(Cmd::Led { - brightness: None, - blink: Some(LedCmd::Blink { state: OnOff::On }) - }) - )); - assert!(parse("led 2 blink on").is_err()); - } - - #[test] - fn startup_set_takes_ai_alone_or_with_a_pose_and_only_verified_modes() { - assert!(matches!( - parse("startup set --ai normal"), - Ok(Cmd::Startup(StartupCmd::Set { - ai: Some(StartupAi::Normal), - pan: None, - current: false, - .. - })) - )); - assert!(parse("startup set --current --ai upper-body").is_ok()); - assert!(parse("startup set --ai none").is_ok()); - // unverified values are not offered - for ai in ["hand", "close-up", "lower-body"] { - assert!(parse(&format!("startup set --ai {ai}")).is_err(), "{ai}"); - } - } - - #[test] - fn wake_without_arguments_still_wakes() { - assert!(matches!(parse("wake"), Ok(Cmd::Wake { setting: None }))); - assert!(matches!( - parse("wake zoom 2.5"), - Ok(Cmd::Wake { - setting: Some(WakeCmd::Zoom { .. }) - }) - )); - assert!(matches!( - parse("wake mode upper-body"), - Ok(Cmd::Wake { - setting: Some(WakeCmd::Mode { - mode: AiMode::UpperBody - }) - }) - )); - } - - #[test] - fn exposure_manual_takes_time_and_gain_and_volume_is_bounded() { - assert!(matches!( - parse("exposure manual --time 100 --gain 7"), - Ok(Cmd::Exposure { - time: Some(100), - gain: Some(7), - .. - }) - )); - assert!(matches!( - parse("exposure manual"), - Ok(Cmd::Exposure { - time: None, - gain: None, - .. - }) - )); - assert!(parse("audio volume 100").is_ok()); - assert!(parse("audio volume 101").is_err()); - assert!(parse("audio volume -1").is_err()); - assert!(matches!( - parse("focus af single"), - Ok(Cmd::Focus(FocusCmd::Af { - mode: AutofocusMode::Single - })) - )); - assert!(parse("focus point 0.3 0.4").is_ok()); - } - - #[test] - fn gimbal_and_zoom_keep_their_syntax_and_gain_subcommands() { - // what the widget and scripts send today - assert!(matches!( - parse("gimbal --pan 10 --tilt -5"), - Ok(Cmd::Gimbal { - pan: Some(_), - tilt: Some(_), - speed: None, - action: None - }) - )); - assert!(matches!( - parse("zoom 2.5"), - Ok(Cmd::Zoom { - factor: Some(_), - speed: None, - stop: None - }) - )); - assert!(matches!( - parse("speed sport"), - Ok(Cmd::Speed { - speed: TrackingSpeed::Sport - }) - )); - assert!(matches!( - parse("gimbal --pan 10 --speed 20"), - Ok(Cmd::Gimbal { speed: Some(_), .. }) - )); - assert!(matches!( - parse("gimbal stop"), - Ok(Cmd::Gimbal { - action: Some(GimbalCmd::Stop), - .. - }) - )); - assert!(matches!( - parse("gimbal center 0.3 0.4"), - Ok(Cmd::Gimbal { - action: Some(GimbalCmd::Center { .. }), - .. - }) - )); - assert!(matches!( - parse("gimbal rate --pan -5 --tilt 3"), - Ok(Cmd::Gimbal { - action: Some(GimbalCmd::Rate { - pan: Some(_), - tilt: Some(_) - }), - .. - }) - )); - assert!(matches!( - parse("zoom 3 --speed 30"), - Ok(Cmd::Zoom { - speed: Some(30), - .. - }) - )); - assert!(matches!( - parse("zoom stop"), - Ok(Cmd::Zoom { - stop: Some(ZoomCmd::Stop), - .. - }) - )); - assert!( - parse("zoom --speed 30").is_err(), - "a speed without a factor" - ); - // a pose and a subcommand are two different requests - assert!(parse("gimbal --pan 10 stop").is_err()); - assert!(matches!( - parse("speed headroom"), - Ok(Cmd::Speed { - speed: TrackingSpeed::Headroom - }) - )); - } - - #[test] - fn framing_commands_parse() { - assert!(matches!( - parse("speed-mode very-fast"), - Ok(Cmd::SpeedMode { - mode: SpeedMode::VeryFast - }) - )); - assert!(parse("speed-mode turbo").is_err()); - assert!(matches!( - parse("framing size medium-close-up"), - Ok(Cmd::Framing(FramingCmd::Size { - size: FramingSize::MediumCloseUp - })) - )); - assert!(matches!( - parse("framing offset --x -0.1 --y 0.05"), - Ok(Cmd::Framing(FramingCmd::Offset { - x: Some(_), - y: Some(_) - })) - )); - assert!(parse("framing auto-zoom on").is_ok()); - assert!(parse("framing zoom-speed slow").is_ok()); - assert!(parse("framing part lower-body").is_ok()); - assert!(parse("framing part feet").is_err()); - } - - #[test] - fn detect_prints_the_model_and_the_streaming_node() { - let found = Detected { - model: &MODELS[0], - device: "/dev/v4l/by-id/usb-X-video-index0".into(), - }; - let (value, text) = detect_output(&found); - assert_eq!(text, "Tiny 2 Lite"); - assert_eq!( - value, - json!({"model": "Tiny 2 Lite", "device": "/dev/v4l/by-id/usb-X-video-index0"}) - ); - } -} diff --git a/crates/obsbot4linux/src/client.rs b/crates/obsbot4linux/src/client.rs deleted file mode 100644 index 2096c31..0000000 --- a/crates/obsbot4linux/src/client.rs +++ /dev/null @@ -1,145 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -//! Client side of the socket: forwarding CLI commands to a running daemon, and `obsbot4linux watch`. - -use crate::cli::Cli; -use crate::proto::{Response, socket_path}; -use obsbot_device::error::{Error, Result}; -use serde_json::{Value, json}; -use std::fs::OpenOptions; -use std::io::{BufRead, BufReader, Write}; -use std::os::unix::net::UnixStream; -use std::os::unix::process::CommandExt; -use std::process::{Command, Stdio}; -use std::time::{Duration, Instant}; - -const REPLY_TIMEOUT: Duration = Duration::from_secs(30); - -/// Sends the command line to the daemon and prints its answer like a direct call. -/// Returns None when no daemon is listening (the caller then runs the command itself). -pub fn forward(cli: &Cli, args: Vec) -> Option { - let stream = UnixStream::connect(socket_path()?).ok()?; - let finish = |msg: String| -> Option { Some(crate::cli::print_error(cli.json, &msg)) }; - stream.set_read_timeout(Some(REPLY_TIMEOUT)).ok()?; - let mut writer = stream.try_clone().ok()?; - if writeln!(writer, "{}", json!({"id": 1, "args": args})).is_err() { - return finish("lost the connection to the daemon".into()); - } - for line in BufReader::new(stream).lines() { - let Ok(line) = line else { - return finish("the daemon did not answer".into()); - }; - // events and other clients' lines are not ours - let Ok(resp) = serde_json::from_str::(&line) else { - continue; - }; - if resp.id != json!(1) { - continue; - } - return Some(if resp.ok { - let result = resp.result.unwrap_or(Value::Null); - crate::cli::print_ok(cli.json, &result, resp.text.as_deref().unwrap_or("")) - } else { - crate::cli::print_error(cli.json, resp.error.as_deref().unwrap_or("unknown error")) - }); - } - finish("the daemon closed the connection".into()) -} - -fn connect_or_start() -> Result { - let path = socket_path().ok_or_else(|| { - Error::Invalid("neither XDG_RUNTIME_DIR nor OBSBOT4LINUX_SOCKET is set".into()) - })?; - if let Ok(s) = UnixStream::connect(&path) { - return Ok(s); - } - 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::legacy::state::path().with_file_name("daemon.log"); - let stderr = log - .parent() - .map_or(Ok(()), std::fs::create_dir_all) - .and_then(|_| OpenOptions::new().create(true).append(true).open(&log)) - .map_or_else(|_| Stdio::null(), Stdio::from); - // detached: own process group, no stdin/stdout, outlives this process - Command::new(exe) - .arg("daemon") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(stderr) - .process_group(0) - .spawn()?; - let deadline = Instant::now() + Duration::from_secs(2); - loop { - match UnixStream::connect(&path) { - Ok(s) => return Ok(s), - Err(e) if Instant::now() >= deadline => { - return Err(Error::Invalid(format!( - "cannot reach the daemon: {e} (its log: {})", - log.display() - ))); - } - Err(_) => std::thread::sleep(Duration::from_millis(50)), - } - } -} - -/// Copies the daemon's lines to `out`. The exit code: 1 when the daemon closed the connection -/// (so a widget can tell a crash from a normal end), 0 when `out` closed. -fn relay(daemon: impl BufRead, mut out: impl Write) -> i32 { - for line in daemon.lines() { - let Ok(line) = line else { break }; - if writeln!(out, "{line}").and_then(|_| out.flush()).is_err() { - return 0; - } - } - eprintln!("obsbot4linux: the daemon closed the connection"); - 1 -} - -/// `obsbot4linux watch`: the daemon's lines (state events and answers) go to stdout, JSON request -/// lines from stdin go to the daemon. Ends when stdin closes (exit 0) or the daemon goes away -/// (exit 1). -pub fn watch() -> Result<()> { - let stream = connect_or_start()?; - let mut writer = stream.try_clone()?; - std::thread::spawn(move || { - std::process::exit(relay(BufReader::new(stream), std::io::stdout())); - }); - for line in std::io::stdin().lock().lines() { - let Ok(line) = line else { break }; - if !line.trim().is_empty() && writeln!(writer, "{line}").is_err() { - break; - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::{self, Cursor}; - - #[test] - fn watch_fails_when_the_daemon_goes_away() { - let mut out = Vec::new(); - assert_eq!(relay(Cursor::new("a\nb\n"), &mut out), 1); - assert_eq!(out, b"a\nb\n"); - } - - struct Closed; - - impl Write for Closed { - fn write(&mut self, _: &[u8]) -> io::Result { - Err(io::ErrorKind::BrokenPipe.into()) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - #[test] - fn watch_ends_normally_when_its_reader_goes_away() { - assert_eq!(relay(Cursor::new("a\n"), Closed), 0); - } -} diff --git a/crates/obsbot4linux/src/daemon.rs b/crates/obsbot4linux/src/daemon.rs deleted file mode 100644 index 04372b6..0000000 --- a/crates/obsbot4linux/src/daemon.rs +++ /dev/null @@ -1,754 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -//! `obsbot4linux daemon`: owns the camera. One worker thread does all camera access (so frames from -//! every client are serialised and sequence numbers stay unique), executes requests with the -//! CLI's own argument parser and polls the camera to notice changes it makes itself. - -use crate::cli::{Cli, Cmd, GimbalCmd, PresetCmd, ViewCmd, ZoneCmd, ZoomCmd, execute_with}; -use crate::proto::{Hub, Request, Response, socket_path}; -use clap::Parser; -use nix::errno::Errno; -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 serde_json::{Value, json}; -use std::collections::HashSet; -use std::fs::{File, OpenOptions}; -use std::io::{BufRead, BufReader}; -use std::os::fd::FromRawFd; -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; -use std::os::unix::net::{UnixListener, UnixStream}; -use std::path::Path; -use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -const TICK: Duration = Duration::from_secs(1); -/// The 0x0104 / zoom queries and the image controls are read at least this often. -const FULL_EVERY: Duration = Duration::from_secs(5); -/// The zone, hand, start pose and preset readbacks change only by a command, so they are asked -/// again after one of ours and otherwise this often, to notice changes made with OBSBOT Center. -const ZONE_EVERY: Duration = Duration::from_secs(60); - -struct Job { - args: Vec, - reply: Sender>, -} - -/// Parses a request with the CLI's own argument parser, like a direct call would. -fn parse_request(args: &[String]) -> std::result::Result { - let cli = Cli::try_parse_from( - std::iter::once("obsbot4linux".to_string()).chain(args.iter().cloned()), - ) - .map_err(|e| e.to_string())?; - if matches!(cli.command, Cmd::Daemon | Cmd::Watch | Cmd::Detect) { - return Err("daemon, watch and detect cannot be run through the daemon".into()); - } - Ok(cli.command) -} - -/// Commands that only read: they neither drop the zone cache nor cause a re-read and an event. -fn read_only(cmd: &Cmd) -> bool { - matches!( - cmd, - Cmd::Status | Cmd::Preset(PresetCmd::List) | Cmd::Zone(ZoneCmd::View(ViewCmd::List)) - ) -} - -/// Stops change no setting: the zone readbacks stay, so a stop is not queued behind a long full -/// read. -fn keeps_zone_cache(cmd: &Cmd) -> bool { - matches!( - cmd, - Cmd::Gimbal { - action: Some(GimbalCmd::Stop), - .. - } | Cmd::Zoom { - stop: Some(ZoomCmd::Stop), - .. - } - ) -} - -/// Which connections want the live pose. It belongs to the connection, not to the camera: a -/// widget that crashes or is closed without saying "off" must not keep the polling running, so a -/// closing connection is removed here. A set of connection ids rather than a counter, so a -/// repeated "on" from one connection is not counted twice and one "off" still turns it off. -#[derive(Default)] -struct PoseDemand(Mutex>); - -impl PoseDemand { - fn set(&self, connection: u64, wanted: bool) { - let mut wanting = self.0.lock().unwrap_or_else(|e| e.into_inner()); - if wanted { - wanting.insert(connection); - } else { - wanting.remove(&connection); - } - } - - fn wanted(&self) -> bool { - !self.0.lock().unwrap_or_else(|e| e.into_inner()).is_empty() - } -} - -fn unavailable() -> Value { - json!({ - "available": false, "model": null, "device": null, - "streaming": false, "streaming_apps": [], "target": null, "capabilities": CAPABILITIES - }) -} - -/// Differences in these auto-adjusted controls are not a change worth a broadcast. -fn stable(state: &Value) -> Value { - let mut s = state.clone(); - // the box follows the person all the time; only `detected` is news - if let Some(target) = s.get_mut("target").and_then(Value::as_object_mut) { - target.remove("box"); - } - if let Some(image) = s.get_mut("image").and_then(Value::as_object_mut) { - for k in ["gain", "white_balance_temperature", "exposure_time"] { - image.remove(k); - } - } - s -} - -struct Worker { - hub: Arc, - /// Whether any client wants the live pose; without one it is only read with the full status. - pose: Arc, - /// Last full state seen from the camera (None while it is unavailable). - last: Option, - last_full: Instant, - /// Zone, hand and start pose readbacks with the time they were read; dropped after a command. - zone: Option<(ZoneInfo, Instant)>, - /// Firmware and model codes, read once per connect: dropped when the camera goes away. - identity: Option, - dirty: bool, - /// Whether the last poll found the camera; None before the first one. - up: Option, -} - -impl Worker { - fn read_full(&mut self, cam: Option<&Camera>) -> Result { - let known = self - .zone - .as_ref() - .filter(|(_, at)| at.elapsed() < ZONE_EVERY) - .map(|(info, _)| info.clone()); - let reused = known.is_some(); - let opened; - let cam = match cam { - Some(cam) => cam, - None => { - opened = Camera::open()?; - &opened - } - }; - let full = cam.full_status_with(known, self.identity.clone())?; - self.remember_identity(Identity { - firmware: full.firmware.clone(), - model_info: full.model_info, - }); - if !reused { - let info = ZoneInfo { - zone: full.zone.clone(), - hand: full.hand.clone(), - startup: full.state.startup, - framing: full.framing.clone(), - presets: full.presets.clone(), - }; - self.zone = Some((info, Instant::now())); - } - serde_json::to_value(full).map_err(|e| Error::Invalid(e.to_string())) - } - - /// Reads everything (with `cam`, else a camera opened for it) and broadcasts when something - /// changed (or `force`). - fn publish_full(&mut self, cam: Option<&Camera>, force: bool) { - self.last_full = Instant::now(); - self.dirty = false; - match self.read_full(cam) { - Ok(state) => { - // coming back from "unavailable" is always news - let force = force || self.up != Some(true); - self.up = Some(true); - let changed = self.last.as_ref().map(stable) != Some(stable(&state)); - if force || changed { - self.hub.broadcast(&state); - } - self.last = Some(state); - } - Err(_) => self.mark_unavailable(), - } - } - - /// Keeps the identity once the camera has answered at least one of the two queries; an - /// all-empty read is tried again with the next full read. - fn remember_identity(&mut self, read: Identity) { - if self.identity.is_none() && (read.firmware.is_some() || read.model_info.is_some()) { - self.identity = Some(read); - } - } - - /// Broadcasts "available: false" once when the camera goes away. - fn mark_unavailable(&mut self) { - self.last = None; - self.identity = None; - if self.up != Some(false) { - self.up = Some(false); - self.hub.broadcast(&unavailable()); - } - } - - /// After a change: nothing is broadcast until the camera has been read again (#107). The - /// status block alone is not enough: zone, hand, image, zoom and audio values come only with - /// a full read, and an event built from the last full state would carry their old values - /// and pull a client's optimistic value back for the length of a full read. Requests that - /// are already queued run first, then one forced full read announces all of them. - fn after_change(&mut self, keep_zone: bool) { - if !keep_zone { - self.zone = None; - } - self.dirty = true; - } - - /// The gimbal pose (only while a client wants it), the tracking target and the streaming - /// applications every tick, so a pose moved by AI tracking or a started video call shows - /// within a second. One broadcast for all of them. Each pose query holds the camera lock, - /// so it is left out when nobody looks at it (#68); `pose` then changes with the full read. - fn publish_live(&mut self, cam: &Camera) { - let Some(last) = self.last.as_mut() else { - return; - }; - let mut changed = false; - if let Some(Ok(pose)) = self.pose.wanted().then(|| cam.live_pose()) { - let pose = serde_json::to_value(pose).unwrap_or(Value::Null); - if last["pose"] != pose { - last["pose"] = pose; - changed = true; - } - } - // box jitter alone is no event: it is kept in `last` for the next broadcast - let tracking = last["status"]["ai_mode"] - .as_str() - .is_some_and(|m| m != "static"); - let target = if tracking { - cam.target() - .ok() - .map(|t| serde_json::to_value(t).unwrap_or(Value::Null)) - } else { - Some(Value::Null) - }; - if let Some(target) = target { - if last["target"]["detected"] != target["detected"] { - changed = true; - } - last["target"] = target; - } - if let Some(device) = last["device"].as_str() { - let streaming = json!(streaming::is_streaming(Path::new(device))); - let apps = streaming::apps_using(Path::new(device)); - let apps = json!(apps); - if last["streaming"] != streaming || last["streaming_apps"] != apps { - last["streaming"] = streaming; - last["streaming_apps"] = apps; - changed = true; - } - } - if changed { - self.hub.broadcast(last); - } - } - - /// One camera handle for the whole tick: each open enumerates `/dev/v4l/by-id` again. - fn tick(&mut self) { - let Ok(cam) = Camera::open() else { - return self.mark_unavailable(); - }; - match cam.status() { - Err(_) => self.mark_unavailable(), - Ok(status) => { - let status = serde_json::to_value(status).unwrap_or(Value::Null); - let status_changed = self.last.as_ref().map(|l| &l["status"]) != Some(&status); - // an AI mode change (a gesture, say) overwrites the framing options - if self.last.as_ref().map(|l| &l["status"]["ai_mode"]) != Some(&status["ai_mode"]) { - self.zone = None; - } - if status_changed || self.last_full.elapsed() >= FULL_EVERY { - self.publish_full(Some(&cam), false); - } else { - self.publish_live(&cam); - } - } - } - } - - fn run(mut self, jobs: Receiver) { - self.publish_full(None, true); - let mut next_tick = Instant::now() + TICK; - loop { - let wait = if self.dirty { - Duration::ZERO - } else { - next_tick.saturating_duration_since(Instant::now()) - }; - match jobs.recv_timeout(wait) { - Ok(job) => { - // from the parsed command: argv can start with a global flag (--json status) - let (result, changes, keep_zone) = match parse_request(&job.args) { - Ok(cmd) => { - let changes = !read_only(&cmd); - let keep_zone = keeps_zone_cache(&cmd); - ( - execute_with(cmd, self.identity.clone()).map_err(|e| e.to_string()), - changes, - keep_zone, - ) - } - Err(e) => (Err(e), false, false), - }; - let changed = changes && result.is_ok(); - let _ = job.reply.send(result); - if changed { - self.after_change(keep_zone); - } - } - // forced: a client waits for this event to confirm its change, even an unchanged one - Err(RecvTimeoutError::Timeout) if self.dirty => self.publish_full(None, true), - Err(RecvTimeoutError::Timeout) => { - self.tick(); - next_tick = Instant::now() + TICK; - } - Err(RecvTimeoutError::Disconnected) => return, - } - } - } -} - -fn serve_client(stream: UnixStream, hub: Arc, pose: Arc, jobs: Sender) { - let Ok(reader) = stream.try_clone() else { - return; - }; - let Ok((id, writer)) = hub.add(stream) else { - return; - }; - for line in BufReader::new(reader).lines() { - let Ok(line) = line else { break }; - if line.trim().is_empty() { - continue; - } - let response = match serde_json::from_str::(&line) { - Err(e) => Response { - id: Value::Null, - ok: false, - result: None, - text: None, - error: Some(format!("bad request: {e}")), - }, - // per connection, so it is answered here and not by the camera worker - Ok(req) => { - let (tx, rx) = mpsc::channel(); - let outcome = if let Ok(Cmd::LivePose { state }) = parse_request(&req.args) { - pose.set(id, state.get()); - Ok(( - json!({"live_pose": state.get()}), - format!("live-pose: {}", if state.get() { "on" } else { "off" }), - )) - } else if jobs - .send(Job { - args: req.args, - reply: tx, - }) - .is_ok() - { - rx.recv() - .unwrap_or_else(|_| Err("daemon is shutting down".into())) - } else { - Err("daemon is shutting down".into()) - }; - match outcome { - Ok((result, text)) => Response { - id: req.id, - ok: true, - result: Some(result), - text: Some(text), - error: None, - }, - Err(error) => Response { - id: req.id, - ok: false, - result: None, - text: None, - error: Some(error), - }, - } - } - }; - if Hub::respond(&writer, &response).is_err() { - break; - } - } - hub.remove(id); - pose.set(id, false); -} - -/// The socket systemd passed (LISTEN_FDS), if any. -fn inherited_listener() -> Option { - let pid: u32 = std::env::var("LISTEN_PID").ok()?.parse().ok()?; - let fds: i32 = std::env::var("LISTEN_FDS").ok()?.parse().ok()?; - (pid == std::process::id() && fds >= 1).then(|| unsafe { UnixListener::from_raw_fd(3) }) -} - -/// The listener and, when this process bound it, the lock that keeps a second daemon from -/// replacing the socket (held as long as the daemon runs). -fn bind() -> Result<(UnixListener, Option>)> { - if let Some(l) = inherited_listener() { - return Ok((l, None)); - } - let path = socket_path().ok_or_else(|| { - Error::Invalid("neither XDG_RUNTIME_DIR nor OBSBOT4LINUX_SOCKET is set".into()) - })?; - let (listener, lock) = bind_at(&path)?; - Ok((listener, Some(lock))) -} - -/// Takes `.lock`, then replaces a stale socket. Two daemons started at once (two -/// `watch` at login) would otherwise both find no listener and the second unlink the first's. -fn bind_at(path: &Path) -> Result<(UnixListener, Flock)> { - let mut lock_path = path.as_os_str().to_owned(); - lock_path.push(".lock"); - let lock = OpenOptions::new() - .write(true) - .create(true) - .truncate(false) - .mode(0o600) - .open(&lock_path)?; - let lock = match Flock::lock(lock, FlockArg::LockExclusiveNonblock) { - Ok(lock) => lock, - Err((_, Errno::EWOULDBLOCK)) => { - return Err(Error::Invalid(format!( - "another daemon holds {}", - Path::new(&lock_path).display() - ))); - } - Err((_, e)) => return Err(std::io::Error::from(e).into()), - }; - if UnixStream::connect(path).is_ok() { - return Err(Error::Invalid(format!( - "a daemon is already listening on {}", - path.display() - ))); - } - let _ = std::fs::remove_file(path); // stale socket of a dead daemon - // no window with a socket others can connect to; still single-threaded here - let old = umask(Mode::from_bits_truncate(0o077)); - let listener = UnixListener::bind(path); - umask(old); - let listener = listener?; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; - Ok((listener, lock)) -} - -pub fn run() -> Result<()> { - state::enable_cache(); - let (listener, _lock) = bind()?; - let hub = Arc::new(Hub::default()); - let pose = Arc::new(PoseDemand::default()); - let (tx, rx) = mpsc::channel(); - let worker = Worker { - hub: hub.clone(), - pose: pose.clone(), - last: None, - last_full: Instant::now(), - zone: None, - identity: None, - dirty: false, - up: None, - }; - let worker = std::thread::spawn(move || worker.run(rx)); - std::thread::spawn(move || { - for stream in listener.incoming() { - match stream { - Ok(stream) => { - let (hub, pose, tx) = (hub.clone(), pose.clone(), tx.clone()); - std::thread::spawn(move || serve_client(stream, hub, pose, tx)); - } - // e.g. EMFILE: retrying at once would spin - Err(e) => { - eprintln!("obsbot4linux: accept: {e}"); - std::thread::sleep(Duration::from_millis(100)); - } - } - } - }); - // without the worker nothing answers; exit so that systemd (Restart=on-failure) restarts us - let how = if worker.join().is_err() { - "panicked" - } else { - "stopped" - }; - Err(Error::Invalid(format!("the camera worker {how}"))) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cli::OnOff; - - #[test] - fn a_lost_camera_is_announced_once_with_null_model_and_device() { - let hub = Arc::new(Hub::default()); - let (srv, client) = UnixStream::pair().unwrap(); - hub.add(srv).unwrap(); - let mut worker = Worker { - hub: hub.clone(), - pose: Arc::default(), - last: Some(json!({"available": true, "model": "Tiny 2 Lite"})), - last_full: Instant::now(), - zone: None, - identity: None, - dirty: false, - up: Some(true), - }; - // every failed poll lands here; only the first one is news - worker.mark_unavailable(); - worker.mark_unavailable(); - assert!(worker.last.is_none()); - drop((worker, hub)); - let events: Vec = BufReader::new(client) - .lines() - .map(|l| serde_json::from_str(&l.unwrap()).unwrap()) - .collect(); - assert_eq!(events.len(), 1); - let state = &events[0]["state"]; - assert_eq!(state["available"], false); - // the widget tells "no camera" by these keys being null, not missing - let keys = state.as_object().unwrap(); - assert!(keys["model"].is_null() && keys["device"].is_null()); - assert_eq!(state["streaming"], false); - assert_eq!(state["streaming_apps"], json!([])); - } - - #[test] - fn a_change_announces_nothing_until_the_camera_is_read_again() { - // the last full state still has the old hand value; broadcasting it after `hand tilt off` - // made the widget's switch jump back for the length of a full read (#107) - let hub = Arc::new(Hub::default()); - let (srv, client) = UnixStream::pair().unwrap(); - hub.add(srv).unwrap(); - let info = ZoneInfo { - zone: None, - hand: None, - startup: None, - framing: None, - presets: None, - }; - let mut worker = Worker { - hub: hub.clone(), - pose: Arc::default(), - last: Some(json!({"available": true, "hand": {"tilt_enabled": true}})), - last_full: Instant::now(), - zone: Some((info.clone(), Instant::now())), - identity: None, - dirty: false, - up: Some(true), - }; - worker.after_change(true); - assert!(worker.dirty, "a full read follows at once"); - assert_eq!( - worker.zone.as_ref().map(|(z, _)| z), - Some(&info), - "a stop keeps the cache" - ); - worker.after_change(false); - assert!(worker.zone.is_none(), "the readbacks are asked again"); - drop((worker, hub)); - let events = BufReader::new(client).lines().count(); - assert_eq!(events, 0, "no event with values from before the command"); - } - - #[test] - fn identity_is_kept_once_answered_and_dropped_with_the_camera() { - let mut worker = Worker { - hub: Arc::default(), - pose: Arc::default(), - last: None, - last_full: Instant::now(), - zone: None, - identity: None, - dirty: false, - up: Some(true), - }; - let none = Identity { - firmware: None, - model_info: None, - }; - let known = Identity { - firmware: Some("6.2.8.1".into()), - model_info: None, - }; - worker.remember_identity(none.clone()); - assert_eq!(worker.identity, None, "a silent camera is asked again"); - worker.remember_identity(known.clone()); - worker.remember_identity(none); - assert_eq!(worker.identity, Some(known), "once per connect"); - worker.mark_unavailable(); - assert_eq!( - worker.identity, None, - "a replugged camera may have new firmware" - ); - } - - #[test] - fn target_box_jitter_is_not_a_change_but_detected_is() { - let at = |detected, x| json!({"target": {"detected": detected, "box": [x, 0, 1, 1]}}); - assert_eq!(stable(&at(true, 0.1)), stable(&at(true, 0.2))); - assert_ne!(stable(&at(true, 0.1)), stable(&at(false, 0.1))); - assert_ne!(stable(&at(true, 0.1)), stable(&json!({"target": null}))); - assert_eq!(unavailable()["target"], Value::Null); - } - - fn parsed(line: &str) -> Cmd { - let args: Vec = line.split(' ').map(String::from).collect(); - parse_request(&args).unwrap() - } - - #[test] - fn reads_are_told_from_the_parsed_command_not_from_argv() { - // scripts call `--json status`; the global flags may stand anywhere - for line in [ - "status", - "--json status", - "status --json", - "preset list", - "--json preset list", - "preset --json list", - "zone view list", - "--json zone view list", - ] { - assert!(read_only(&parsed(line)), "{line}"); - } - for line in [ - "led 2", - "--json led 2", - "preset recall 1", - "zone view go 0", - "zone view save a", - "hand pan off", - "--json gimbal --pan 3", - "gimbal stop", - "gimbal center 0.3 0.4", - "zoom 2 --speed 30", - "framing size close-up", - "speed-mode auto", - ] { - assert!(!read_only(&parsed(line)), "{line}"); - } - assert!(parse_request(&["--json".into(), "watch".into()]).is_err()); - } - - #[test] - fn stops_keep_the_zone_cache_other_moves_drop_it() { - for line in ["gimbal stop", "zoom stop", "--json gimbal stop"] { - let cmd = parsed(line); - assert!(keeps_zone_cache(&cmd) && !read_only(&cmd), "{line}"); - } - for line in ["gimbal --pan 1", "gimbal rate --pan 1", "zoom 2", "led 1"] { - assert!(!keeps_zone_cache(&parsed(line)), "{line}"); - } - } - - #[test] - fn preset_changes_drop_the_cache_so_the_new_list_is_broadcast() { - // the preset list is cached with the zone data; a preset save, rename or delete must - // not keep it, or open widgets would show the old list for up to a minute - for line in ["preset save 1", "preset rename 1 desk", "preset delete 1"] { - let cmd = parsed(line); - assert!(!read_only(&cmd) && !keeps_zone_cache(&cmd), "{line}"); - } - } - - #[test] - fn the_pose_is_polled_while_any_connection_wants_it() { - let demand = PoseDemand::default(); - assert!(!demand.wanted(), "a new daemon polls nothing"); - demand.set(1, true); - demand.set(2, true); - // a widget closing must not switch the pose off for the other one - demand.set(1, false); - assert!(demand.wanted()); - demand.set(2, false); - assert!(!demand.wanted()); - } - - #[test] - fn repeated_on_from_one_connection_needs_only_one_off() { - // a reconnecting widget may say "on" again; a counter would never get back to zero - let demand = PoseDemand::default(); - demand.set(7, true); - demand.set(7, true); - demand.set(7, false); - assert!(!demand.wanted()); - demand.set(7, false); // "off" without "on" is harmless - assert!(!demand.wanted()); - } - - #[test] - fn live_pose_is_a_request_for_the_connection_not_a_camera_command() { - assert!(matches!( - parsed("live-pose on"), - Cmd::LivePose { state: OnOff::On } - )); - assert!(matches!( - parsed("--json live-pose off"), - Cmd::LivePose { state: OnOff::Off } - )); - assert!(parse_request(&["live-pose".into(), "maybe".into()]).is_err()); - assert!(CAPABILITIES.contains(&"live_pose_on_demand")); - } - - fn socket_in(tag: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("o4l-sock-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir.join("obsbot4linux.sock") - } - - #[test] - fn a_second_daemon_does_not_take_over_the_socket() { - let path = socket_in("second"); - // a daemon that holds the lock but has not bound yet: no listener to find - let mut lock_path = path.as_os_str().to_owned(); - lock_path.push(".lock"); - let file = File::create(&lock_path).unwrap(); - let starting = Flock::lock(file, FlockArg::LockExclusiveNonblock).unwrap(); - let err = bind_at(&path).unwrap_err().to_string(); - assert!(err.contains("another daemon holds"), "{err}"); - assert!(!path.exists(), "the loser creates no socket"); - drop(starting); - let (listener, _lock) = bind_at(&path).unwrap(); - let mode = std::fs::metadata(&path).unwrap().permissions().mode(); - assert_eq!(mode & 0o777, 0o600); - UnixStream::connect(&path).unwrap(); - listener.accept().unwrap(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn a_stale_socket_is_replaced() { - let path = socket_in("stale"); - drop(bind_at(&path).unwrap()); // a dead daemon leaves its socket and lock file - assert!(path.exists() && UnixStream::connect(&path).is_err()); - let (listener, _lock) = bind_at(&path).unwrap(); - UnixStream::connect(&path).unwrap(); - listener.accept().unwrap(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } -} diff --git a/crates/obsbot4linux/src/main.rs b/crates/obsbot4linux/src/main.rs index 3fd813b..a7e5bf7 100644 --- a/crates/obsbot4linux/src/main.rs +++ b/crates/obsbot4linux/src/main.rs @@ -1,12 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later -//! obsbot4linux command line interface. +//! `obsbot4linux`: the command line (design spec 6.2), a pure client of protocol 2. Track C +//! writes it; the 0.13 command line stays on `main` for 0.14.x. -mod cli; -mod client; -mod daemon; -mod proto; +use std::process::ExitCode; -fn main() { - std::process::exit(cli::run()); +fn main() -> ExitCode { + eprintln!("obsbot4linux: the 1.0 command line is not built yet (stage W0 of the 1.0 rebuild)"); + ExitCode::FAILURE } diff --git a/crates/obsbot4linux/src/proto.rs b/crates/obsbot4linux/src/proto.rs deleted file mode 100644 index 689f4e5..0000000 --- a/crates/obsbot4linux/src/proto.rs +++ /dev/null @@ -1,223 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -//! Socket protocol (JSON lines) shared by the daemon and its clients. -//! -//! Request: `{"id":1,"args":["fov","wide"]}` (args are the CLI arguments) -//! Response: `{"id":1,"ok":true,"result":...,"text":"..."}` or `{"id":1,"ok":false,"error":"..."}` -//! Event: `{"event":"state","state":{...}}` to every client after each change. - -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::io::{self, Write}; -use std::net::Shutdown; -use std::os::unix::net::UnixStream; -use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -/// `OBSBOT4LINUX_SOCKET`, else `$XDG_RUNTIME_DIR/obsbot4linux.sock`. There is no fallback to a shared -/// directory such as /tmp: another local user could create the socket first. -pub fn socket_path() -> Option { - if let Some(p) = std::env::var_os("OBSBOT4LINUX_SOCKET") { - return Some(p.into()); - } - std::env::var_os("XDG_RUNTIME_DIR").map(|d| PathBuf::from(d).join("obsbot4linux.sock")) -} - -#[derive(Debug, Deserialize, PartialEq)] -pub struct Request { - pub id: Value, - pub args: Vec, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -pub struct Response { - pub id: Value, - pub ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - /// The text the CLI prints without --json. - #[serde(skip_serializing_if = "Option::is_none")] - pub text: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -pub fn state_event(state: &Value) -> Value { - json!({"event": "state", "state": state}) -} - -type Writer = Arc>; - -fn write_line(w: &Writer, line: &Value) -> io::Result<()> { - let mut s = w.lock().unwrap_or_else(|e| e.into_inner()); - let mut buf = line.to_string(); - buf.push('\n'); - s.write_all(buf.as_bytes()) -} - -/// The connected clients. Broadcasts go to all of them; a client that cannot be written to -/// (gone, or not reading) is dropped and its connection shut down, so both ends see EOF. -#[derive(Default)] -pub struct Hub { - clients: Mutex>, - last: Mutex>, - next: AtomicU64, -} - -impl Hub { - /// Registers a client and sends it the latest state, if there is one. - pub fn add(&self, stream: UnixStream) -> io::Result<(u64, Writer)> { - stream.set_write_timeout(Some(Duration::from_secs(2)))?; - let id = self.next.fetch_add(1, Ordering::Relaxed); - let writer: Writer = Arc::new(Mutex::new(stream)); - let mut clients = self.clients.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(state) = self.last.lock().unwrap_or_else(|e| e.into_inner()).as_ref() { - write_line(&writer, &state_event(state))?; - } - clients.push((id, writer.clone())); - Ok((id, writer)) - } - - pub fn remove(&self, id: u64) { - self.clients - .lock() - .unwrap_or_else(|e| e.into_inner()) - .retain(|(i, _)| *i != id); - } - - pub fn respond(writer: &Writer, response: &Response) -> io::Result<()> { - write_line( - writer, - &serde_json::to_value(response).unwrap_or(Value::Null), - ) - } - - pub fn broadcast(&self, state: &Value) { - let event = state_event(state); - let mut clients = self.clients.lock().unwrap_or_else(|e| e.into_inner()); - *self.last.lock().unwrap_or_else(|e| e.into_inner()) = Some(state.clone()); - clients.retain(|(_, w)| { - let ok = write_line(w, &event).is_ok(); - if !ok { - // a timed-out write can leave half a line behind; nothing may follow it - let s = w.lock().unwrap_or_else(|e| e.into_inner()); - let _ = s.shutdown(Shutdown::Both); - } - ok - }); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::{BufRead, BufReader}; - - #[test] - fn request_and_response_serialise_as_documented() { - let r: Request = serde_json::from_str(r#"{"id":1,"args":["fov","wide"]}"#).unwrap(); - assert_eq!( - r, - Request { - id: json!(1), - args: vec!["fov".into(), "wide".into()] - } - ); - - let ok = Response { - id: json!(1), - ok: true, - result: Some(json!({"a": 1})), - text: None, - error: None, - }; - assert_eq!( - serde_json::to_value(&ok).unwrap(), - json!({"id": 1, "ok": true, "result": {"a": 1}}) - ); - let err = Response { - id: json!("x"), - ok: false, - result: None, - text: None, - error: Some("boom".into()), - }; - assert_eq!( - serde_json::to_value(&err).unwrap(), - json!({"id": "x", "ok": false, "error": "boom"}) - ); - assert_eq!( - state_event(&json!({"available": true})), - json!({"event": "state", "state": {"available": true}}) - ); - } - - fn read_line(s: UnixStream) -> Value { - let mut line = String::new(); - BufReader::new(s).read_line(&mut line).unwrap(); - serde_json::from_str(&line).unwrap() - } - - #[test] - fn every_client_gets_the_event_and_new_clients_the_last_state() { - let hub = Hub::default(); - let (a_srv, a) = UnixStream::pair().unwrap(); - let (b_srv, b) = UnixStream::pair().unwrap(); - hub.add(a_srv).unwrap(); - hub.add(b_srv).unwrap(); - - hub.broadcast(&json!({"led": 2})); - assert_eq!(read_line(a), state_event(&json!({"led": 2}))); - assert_eq!(read_line(b), state_event(&json!({"led": 2}))); - - let (c_srv, c) = UnixStream::pair().unwrap(); - hub.add(c_srv).unwrap(); - assert_eq!( - read_line(c), - state_event(&json!({"led": 2})), - "late client starts from the last state" - ); - } - - #[test] - fn a_closed_client_is_dropped_and_does_not_stop_the_broadcast() { - let hub = Hub::default(); - let (gone_srv, gone) = UnixStream::pair().unwrap(); - let (live_srv, live) = UnixStream::pair().unwrap(); - hub.add(gone_srv).unwrap(); - hub.add(live_srv).unwrap(); - drop(gone); - hub.broadcast(&json!(1)); - hub.broadcast(&json!(2)); - assert_eq!(hub.clients.lock().unwrap().len(), 1); - drop(hub); - assert_eq!(BufReader::new(live).lines().count(), 2); - } - - #[test] - fn a_client_that_does_not_read_is_disconnected() { - let hub = Hub::default(); - let (srv, client) = UnixStream::pair().unwrap(); - // serve_client's reader is a clone of the same socket - let reader = srv.try_clone().unwrap(); - hub.add(srv).unwrap(); - reader - .set_write_timeout(Some(Duration::from_millis(10))) - .unwrap(); - // larger than the socket buffer: the write times out because nobody reads - hub.broadcast(&json!("x".repeat(4 << 20))); - assert!(hub.clients.lock().unwrap().is_empty()); - - // both ends see EOF instead of waiting forever - let timeout = Some(Duration::from_secs(1)); - reader.set_read_timeout(timeout).unwrap(); - let mut line = String::new(); - assert_eq!(BufReader::new(reader).read_line(&mut line).unwrap(), 0); - client.set_read_timeout(timeout).unwrap(); - let mut rest = Vec::new(); - std::io::Read::read_to_end(&mut &client, &mut rest).unwrap(); - assert!(!rest.ends_with(b"\n"), "the partial line is the last data"); - } -} diff --git a/crates/obsbot4linux/tests/cli_surface.rs b/crates/obsbot4linux/tests/cli_surface.rs deleted file mode 100644 index dcfadcc..0000000 --- a/crates/obsbot4linux/tests/cli_surface.rs +++ /dev/null @@ -1,75 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -//! CLI surface guard of the groundwork stage (rebuild plan, stage G): the `--help` text of the -//! CLI and of every subcommand, compared with `tests/fixtures/cli-surface.txt`, which was -//! written from the 0.13 code before the workspace move, and `--version`. Only `--help` and -//! `--version` run: nothing opens a device or a socket. -//! -//! `CLI_SURFACE_BLESS=1 cargo test --test cli_surface` writes the file: once when this test is -//! added, and on `main` in a change that alters a help text on purpose. A move never writes it; -//! every other run compares. - -use std::process::Command; - -const FIXTURE: &str = concat!( - env!("CARGO_MANIFEST_DIR"), - "/tests/fixtures/cli-surface.txt" -); - -fn run(args: &[&str]) -> String { - let out = Command::new(env!("CARGO_BIN_EXE_obsbot4linux")) - .args(args) - .output() - .expect("obsbot4linux runs"); - assert!(out.status.success(), "obsbot4linux {args:?} failed"); - String::from_utf8(out.stdout).expect("UTF-8 output") -} - -/// The names in the "Commands:" section of a help text: the lines indented by exactly two -/// spaces (a longer indent continues a description). -fn subcommands(help: &str) -> Vec { - help.lines() - .skip_while(|l| *l != "Commands:") - .skip(1) - .take_while(|l| l.starts_with(' ')) - .filter(|l| !l.starts_with(" ")) - .filter_map(|l| l.split_whitespace().next()) - .filter(|name| *name != "help") - .map(String::from) - .collect() -} - -fn walk(path: &mut Vec, out: &mut String) { - let mut args: Vec<&str> = path.iter().map(String::as_str).collect(); - args.push("--help"); - let help = run(&args); - out.push_str(&format!("=== obsbot4linux {}\n{help}", args.join(" "))); - for sub in subcommands(&help) { - path.push(sub); - walk(path, out); - path.pop(); - } -} - -#[test] -fn help_texts_are_unchanged() { - let mut out = String::new(); - walk(&mut Vec::new(), &mut out); - // a hidden subcommand is not listed under "Commands:" - walk(&mut vec!["gimbal".into(), "center".into()], &mut out); - if std::env::var_os("CLI_SURFACE_BLESS").is_some() { - std::fs::write(FIXTURE, &out).unwrap(); - return; - } - let recorded = std::fs::read_to_string(FIXTURE).expect("tests/fixtures/cli-surface.txt"); - assert_eq!(out, recorded, "a help text of the CLI changed"); -} - -#[test] -fn version_is_the_package_version() { - // the workspace move must keep the version the binary reports - assert_eq!( - run(&["--version"]), - format!("obsbot4linux {}\n", env!("CARGO_PKG_VERSION")) - ); -} diff --git a/crates/obsbot4linux/tests/fixtures/cli-surface.txt b/crates/obsbot4linux/tests/fixtures/cli-surface.txt deleted file mode 100644 index 2dcd923..0000000 --- a/crates/obsbot4linux/tests/fixtures/cli-surface.txt +++ /dev/null @@ -1,1126 +0,0 @@ -=== obsbot4linux --help -Control the OBSBOT Tiny 2 Lite - -Usage: obsbot4linux [OPTIONS] - -Commands: - daemon Run the daemon: owns the camera, serves the socket, broadcasts state changes - watch Stream state events as JSON lines and forward JSON requests from stdin (for widgets) - live-pose Ask the daemon to poll the gimbal pose for this connection (a `watch` request; the daemon polls it every second only while a connection wants it). Not usable as a one-off call - detect Exit 0 and print the model (--json: model and video device) when a supported camera is connected, exit 1 with no output when not. Enumeration only: no daemon, no camera command - status Show all settings (the camera's status block, tracking readback, zoom, image controls, remembered state) - sleep Put the camera to sleep - wake Wake the camera, or set the stored wake-up zoom (`wake zoom`) or the tracking mode at power-on (`wake mode`) - tracking AI tracking mode - speed Tracking type: standard, sport (fixed fast gain) or headroom (more room above the head) - speed-mode Fine tracking speed, from lazy to very-fast, or auto. Ignored while `speed sport` is set or the part mode is not normal - framing Framing options of the human tracking; an AI mode change overwrites them - hdr HDR - exposure Exposure: automatic metering over the whole picture (global) or on faces (face), or manual, optionally with the shutter time and gain (`image gain` sets the gain too; `image exposure_time` is refused unless the exposure is already manual) - zoom Zoom factor 1.0-4.0; with --speed the camera zooms smoothly instead of jumping - gimbal Move the gimbal to an absolute pose in degrees (switches tracking to static first), or `gimbal stop|rate` - fov Field of view (ignored by the camera while it sleeps) - led Status LED: brightness 0 (off) to 3, or `led blink on|off` - mirror Mirror the image - invert Invert view and gimbal (reverses the gimbal's pan direction) - auto-sleep Auto-sleep: "off" or a time in seconds - sleep-background Picture shown while the camera sleeps - sleep-background-mirror Mirror the sleep background - audio Microphone settings - gesture Gesture control - hand Hand tracking: which hand, return position, area, axes - zone Zone tracking - focus Focus: autofocus mode, continuous autofocus, manual focus - startup Initial state: the pose the camera moves to when it powers on - image UVC image control, e.g. "image brightness 128" (names are in "status") - preset Presets stored in the camera (slots 0-2) - factory-reset Reset the camera to factory settings (it restarts and deletes its presets) - help Print this message or the help of the given subcommand(s) - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help - -V, --version Print version -=== obsbot4linux daemon --help -Run the daemon: owns the camera, serves the socket, broadcasts state changes - -Usage: obsbot4linux daemon [OPTIONS] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux watch --help -Stream state events as JSON lines and forward JSON requests from stdin (for widgets) - -Usage: obsbot4linux watch [OPTIONS] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux live-pose --help -Ask the daemon to poll the gimbal pose for this connection (a `watch` request; the daemon polls it every second only while a connection wants it). Not usable as a one-off call - -Usage: obsbot4linux live-pose [OPTIONS] - -Arguments: - [possible values: on, off] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux detect --help -Exit 0 and print the model (--json: model and video device) when a supported camera is connected, exit 1 with no output when not. Enumeration only: no daemon, no camera command - -Usage: obsbot4linux detect [OPTIONS] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux status --help -Show all settings (the camera's status block, tracking readback, zoom, image controls, remembered state) - -Usage: obsbot4linux status [OPTIONS] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux sleep --help -Put the camera to sleep - -Usage: obsbot4linux sleep [OPTIONS] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux wake --help -Wake the camera, or set the stored wake-up zoom (`wake zoom`) or the tracking mode at power-on (`wake mode`) - -Usage: obsbot4linux wake [OPTIONS] [COMMAND] - -Commands: - zoom Stored wake-up zoom, factor 1.0-4.0 (the camera stores 0-100 percent). Effect unknown on the Tiny 2 Lite: no change at power-on or on waking from sleep - mode Tracking mode the camera starts in at power-on, not on waking from sleep (the current mode stays) - help Print this message or the help of the given subcommand(s) - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux wake zoom --help -Stored wake-up zoom, factor 1.0-4.0 (the camera stores 0-100 percent). Effect unknown on the Tiny 2 Lite: no change at power-on or on waking from sleep - -Usage: obsbot4linux wake zoom [OPTIONS] - -Arguments: - - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux wake mode --help -Tracking mode the camera starts in at power-on, not on waking from sleep (the current mode stays) - -Usage: obsbot4linux wake mode [OPTIONS] - -Arguments: - [possible values: static, normal, upper-body, close-up, headless, lower-body, hand, group] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux tracking --help -AI tracking mode - -Usage: obsbot4linux tracking [OPTIONS] - -Arguments: - [possible values: static, normal, upper-body, close-up, headless, lower-body, hand, group] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux speed --help -Tracking type: standard, sport (fixed fast gain) or headroom (more room above the head) - -Usage: obsbot4linux speed [OPTIONS] - -Arguments: - [possible values: standard, headroom, sport] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux speed-mode --help -Fine tracking speed, from lazy to very-fast, or auto. Ignored while `speed sport` is set or the part mode is not normal - -Usage: obsbot4linux speed-mode [OPTIONS] - -Arguments: - [possible values: lazy, slow, normal, fast, very-fast, auto] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux framing --help -Framing options of the human tracking; an AI mode change overwrites them - -Usage: obsbot4linux framing [OPTIONS] - -Commands: - auto-zoom Auto zoom of the human tracking on or off - size Size of the auto zoom framing - zoom-speed How fast the auto zoom follows - offset Shift the framing by a fraction of the frame, -0.16 to 0.16 - part Which part of the body the framing follows - help Print this message or the help of the given subcommand(s) - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux framing auto-zoom --help -Auto zoom of the human tracking on or off - -Usage: obsbot4linux framing auto-zoom [OPTIONS] - -Arguments: - [possible values: on, off] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux framing size --help -Size of the auto zoom framing - -Usage: obsbot4linux framing size [OPTIONS] - -Arguments: - [possible values: long-shot, medium-long-shot, medium-shot, medium-close-up, close-up, big-close-up] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux framing zoom-speed --help -How fast the auto zoom follows - -Usage: obsbot4linux framing zoom-speed [OPTIONS] - -Arguments: - [possible values: slow, medium, fast] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux framing offset --help -Shift the framing by a fraction of the frame, -0.16 to 0.16 - -Usage: obsbot4linux framing offset [OPTIONS] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --x - --direct Do not use a running daemon; talk to the camera directly - --y - -h, --help Print help -=== obsbot4linux framing part --help -Which part of the body the framing follows - -Usage: obsbot4linux framing part [OPTIONS] - -Arguments: - [possible values: normal, head-removal, lower-body] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux hdr --help -HDR - -Usage: obsbot4linux hdr [OPTIONS] - -Arguments: - [possible values: on, off] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --direct Do not use a running daemon; talk to the camera directly - -h, --help Print help -=== obsbot4linux exposure --help -Exposure: automatic metering over the whole picture (global) or on faces (face), or manual, optionally with the shutter time and gain (`image gain` sets the gain too; `image exposure_time` is refused unless the exposure is already manual) - -Usage: obsbot4linux exposure [OPTIONS] - -Arguments: - [possible values: global, face, manual] - -Options: - --json Print machine-readable JSON (errors too, as {"ok":false,"error":...}) - --time