From 730406034ab5a88984715767ed25567f56fca216 Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 17:54:59 +0100 Subject: [PATCH 1/7] obsbot-proto: protocol 2 types, description format and canonical JSON Adds the crate crates/obsbot-proto with the protocol 2 envelopes (request, reply, event, error), the device and description types with their builders, state and patch types with path helpers, the id and value helpers, and stubs for client, validate (holding Refusal) and schema. Maps are sorted, integral numbers are written without a fraction, and public enums carry a catch-all variant so additive values of a newer daemon still parse. tests/types.rs round-trips every line of tests/golden/types.jsonl byte for byte through its type. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 71 +++ Cargo.toml | 4 +- crates/obsbot-proto/Cargo.toml | 13 + crates/obsbot-proto/src/client.rs | 3 + crates/obsbot-proto/src/description.rs | 563 +++++++++++++++++++ crates/obsbot-proto/src/ids.rs | 82 +++ crates/obsbot-proto/src/lib.rs | 55 ++ crates/obsbot-proto/src/message.rs | 463 +++++++++++++++ crates/obsbot-proto/src/schema.rs | 3 + crates/obsbot-proto/src/state.rs | 197 +++++++ crates/obsbot-proto/src/validate.rs | 23 + crates/obsbot-proto/src/values.rs | 103 ++++ crates/obsbot-proto/tests/golden/types.jsonl | 65 +++ crates/obsbot-proto/tests/types.rs | 168 ++++++ 14 files changed, 1812 insertions(+), 1 deletion(-) create mode 100644 crates/obsbot-proto/Cargo.toml create mode 100644 crates/obsbot-proto/src/client.rs create mode 100644 crates/obsbot-proto/src/description.rs create mode 100644 crates/obsbot-proto/src/ids.rs create mode 100644 crates/obsbot-proto/src/lib.rs create mode 100644 crates/obsbot-proto/src/message.rs create mode 100644 crates/obsbot-proto/src/schema.rs create mode 100644 crates/obsbot-proto/src/state.rs create mode 100644 crates/obsbot-proto/src/validate.rs create mode 100644 crates/obsbot-proto/src/values.rs create mode 100644 crates/obsbot-proto/tests/golden/types.jsonl create mode 100644 crates/obsbot-proto/tests/types.rs diff --git a/Cargo.lock b/Cargo.lock index 6ec3088..84b3119 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" @@ -176,6 +182,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "obsbot-proto" +version = "0.13.2" +dependencies = [ + "schemars", + "serde", + "serde_json", +] + [[package]] name = "obsbot4linux" version = "0.13.2" @@ -211,6 +226,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 +301,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-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..2c5fcd2 --- /dev/null +++ b/crates/obsbot-proto/src/schema.rs @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Generation of `schema/protocol-2.json`. Written in W0 task 3. 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..fa670c7 --- /dev/null +++ b/crates/obsbot-proto/src/validate.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The validator. Written in W0 task 2. + +use crate::message::ErrorCode; + +/// 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(), + } + } +} 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/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/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); + } +} From dd1c18d67fa499362f3bc3a5139c4f42264a699b Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 18:01:29 +0100 Subject: [PATCH 2/7] obsbot-proto: validator for set, do and get, and the description self-check validate_set, validate_do and validate_get check a request against a device description and its state before anything reaches the camera. One refusal order for the whole request: invalid, unsupported, out_of_range, conflict, asleep, then path order. check_value covers Fit::Write and Fit::State; check_description reports the flaws of an inconsistent description. Fixes #173 Co-Authored-By: Claude Sonnet 5.5 --- crates/obsbot-proto/src/validate.rs | 614 +++++++++++++++++++++++++- crates/obsbot-proto/tests/validate.rs | 489 ++++++++++++++++++++ 2 files changed, 1102 insertions(+), 1 deletion(-) create mode 100644 crates/obsbot-proto/tests/validate.rs diff --git a/crates/obsbot-proto/src/validate.rs b/crates/obsbot-proto/src/validate.rs index fa670c7..6c0c609 100644 --- a/crates/obsbot-proto/src/validate.rs +++ b/crates/obsbot-proto/src/validate.rs @@ -1,8 +1,19 @@ // SPDX-License-Identifier: GPL-3.0-or-later -//! The validator. Written in W0 task 2. +//! 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)] @@ -21,3 +32,604 @@ impl Refusal { } } } + +/// 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/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}" + ); + } +} From 272ac3568aeccee483a17f59fb89c6c71975ffd8 Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 18:01:25 +0100 Subject: [PATCH 3/7] obsbot-proto: generate schema/protocol-2.json with a stale test obsbot_proto::schema::protocol_schema builds the draft 2020-12 schema (oneOf Request and ServerMessage, $defs including Description) from the protocol types; schema_text is the file content. tests/schema.rs fails when schema/protocol-2.json is missing or differs, and rewrites it when OBSBOT_REGENERATE is set. A second test checks that $defs holds every envelope, the description types and all 12 error codes. Fixes #174 Co-Authored-By: Claude Sonnet 5.5 --- crates/obsbot-proto/src/schema.rs | 34 +- crates/obsbot-proto/tests/schema.rs | 54 ++ schema/protocol-2.json | 1001 +++++++++++++++++++++++++++ 3 files changed, 1088 insertions(+), 1 deletion(-) create mode 100644 crates/obsbot-proto/tests/schema.rs create mode 100644 schema/protocol-2.json diff --git a/crates/obsbot-proto/src/schema.rs b/crates/obsbot-proto/src/schema.rs index 2c5fcd2..3b01502 100644 --- a/crates/obsbot-proto/src/schema.rs +++ b/crates/obsbot-proto/src/schema.rs @@ -1,3 +1,35 @@ // SPDX-License-Identifier: GPL-3.0-or-later -//! Generation of `schema/protocol-2.json`. Written in W0 task 3. +//! 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/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/schema/protocol-2.json b/schema/protocol-2.json new file mode 100644 index 0000000..54ad6fe --- /dev/null +++ b/schema/protocol-2.json @@ -0,0 +1,1001 @@ +{ + "$defs": { + "Action": { + "description": "One action of a device (4.2).", + "properties": { + "asleep": { + "$ref": "#/$defs/Asleep" + }, + "label": { + "type": "string" + }, + "params": { + "additionalProperties": { + "$ref": "#/$defs/Setting" + }, + "default": {}, + "description": "Parameter name → setting; empty for an action without parameters.", + "type": "object" + }, + "requires": { + "items": { + "$ref": "#/$defs/Requirement" + }, + "type": "array" + }, + "result": { + "additionalProperties": { + "$ref": "#/$defs/Setting" + }, + "type": [ + "object", + "null" + ] + }, + "warning": { + "items": { + "$ref": "#/$defs/Warning" + }, + "type": "array" + } + }, + "required": [ + "label", + "asleep" + ], + "type": "object" + }, + "Asleep": { + "anyOf": [ + { + "enum": [ + "accepted", + "ignored", + "unknown" + ], + "type": "string" + }, + { + "type": "string" + } + ] + }, + "Bound": { + "anyOf": [ + { + "type": "number" + }, + { + "additionalProperties": { + "type": "number" + }, + "type": "object" + } + ], + "description": "A bound, step or precision: one number for `number`; for `range`, `pose`, `point` and\n`box` an object of the same shape as the value, one number per component." + }, + "Description": { + "description": "What a model offers (4.2): the part of a `Device` that `descriptions/.json` holds.", + "properties": { + "actions": { + "additionalProperties": { + "$ref": "#/$defs/Action" + }, + "description": "Action name → action.", + "type": "object" + }, + "limits": { + "additionalProperties": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "description": "List path → maximum item count, where the camera has one.", + "type": "object" + }, + "settings": { + "additionalProperties": { + "$ref": "#/$defs/Setting" + }, + "description": "Leaf path → setting.", + "type": "object" + } + }, + "required": [ + "settings", + "actions", + "limits" + ], + "type": "object" + }, + "Device": { + "description": "One entry of the device list (4.2).", + "properties": { + "actions": { + "additionalProperties": { + "$ref": "#/$defs/Action" + }, + "description": "Action name → action.", + "type": "object" + }, + "available": { + "type": "boolean" + }, + "firmware": { + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "`sn:` or `port:` (5.5).", + "type": "string" + }, + "limits": { + "additionalProperties": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "description": "List path → maximum item count, where the camera has one.", + "type": "object" + }, + "model": { + "description": "The model key (`tiny2lite`, `fake-a`, `fake-b`), or `unknown` for an unsupported device.", + "type": "string" + }, + "name": { + "type": "string" + }, + "replaced_by": { + "description": "Set only on the old `port:` entry during an identity hand-over (5.5).", + "type": [ + "string", + "null" + ] + }, + "serial": { + "type": [ + "string", + "null" + ] + }, + "settings": { + "additionalProperties": { + "$ref": "#/$defs/Setting" + }, + "description": "Leaf path → setting.", + "type": "object" + }, + "support": { + "$ref": "#/$defs/Support" + }, + "usb": { + "$ref": "#/$defs/Usb" + }, + "video_node": { + "description": "`/dev/videoN` of the video node with sysfs index 0.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "name", + "model", + "support", + "available", + "usb", + "settings", + "actions", + "limits" + ], + "type": "object" + }, + "Empty": { + "type": "object" + }, + "ErrorBody": { + "description": "The `error` payload (4.1, Errors).", + "properties": { + "applied": { + "description": "`camera`: the leaf paths written before the failure.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "code": { + "$ref": "#/$defs/ErrorCode" + }, + "failed": { + "description": "`camera`: the leaf path or action that failed.", + "type": [ + "string", + "null" + ] + }, + "message": { + "description": "English, for logs.", + "type": "string" + }, + "rev": { + "description": "Present whenever the daemon re-read the device before answering.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "supported": { + "description": "`protocol`: the protocol numbers the daemon speaks.", + "items": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "ErrorCode": { + "anyOf": [ + { + "enum": [ + "protocol", + "invalid", + "unknown_device", + "unsupported", + "unsupported_device", + "out_of_range", + "conflict", + "unavailable", + "asleep", + "busy", + "camera", + "internal" + ], + "type": "string" + }, + { + "type": "string" + } + ], + "description": "The error codes the daemon sends (4.1). A code this crate does not know parses as\n`Other`; clients treat it as `internal` (4.1 rule 7)." + }, + "Event": { + "description": "An event (only on connections with `subscribe: true`).", + "oneOf": [ + { + "description": "The full state of one device.", + "properties": { + "device": { + "type": "string" + }, + "event": { + "const": "state", + "type": "string" + }, + "rev": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "state": { + "$ref": "#/$defs/State" + } + }, + "required": [ + "event", + "device", + "rev", + "state" + ], + "type": "object" + }, + { + "description": "The full device list.", + "properties": { + "devices": { + "items": { + "$ref": "#/$defs/Device" + }, + "type": "array" + }, + "event": { + "const": "devices", + "type": "string" + } + }, + "required": [ + "event", + "devices" + ], + "type": "object" + } + ] + }, + "ExtensionUnit": { + "properties": { + "controls": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "guid": { + "description": "Lower-case `8-4-4-4-12` form.", + "type": "string" + }, + "unit": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "unit", + "guid", + "controls" + ], + "type": "object" + }, + "Hello": { + "description": "The `hello` verb.", + "properties": { + "client": { + "description": "`\" \"` of the client.", + "type": "string" + }, + "live": { + "default": [], + "description": "Live topics (4.1 rule 8); empty when left out.", + "items": { + "type": "string" + }, + "type": "array" + }, + "protocol": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "subscribe": { + "default": true, + "description": "Events for this connection; `true` when left out on the wire.", + "type": "boolean" + } + }, + "required": [ + "protocol", + "client" + ], + "type": "object" + }, + "HelloOk": { + "properties": { + "daemon": { + "description": "The daemon's version.", + "type": "string" + }, + "devices": { + "items": { + "$ref": "#/$defs/Device" + }, + "type": "array" + }, + "protocol": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "states": { + "additionalProperties": { + "$ref": "#/$defs/RevState" + }, + "description": "Device id → its rev and state.", + "type": "object" + } + }, + "required": [ + "protocol", + "daemon", + "devices", + "states" + ], + "type": "object" + }, + "OkBody": { + "anyOf": [ + { + "$ref": "#/$defs/HelloOk", + "description": "The reply to `hello`." + }, + { + "$ref": "#/$defs/RevState", + "description": "The reply to `get`." + }, + { + "$ref": "#/$defs/RevOk", + "description": "The reply to `set` and `do`." + }, + { + "$ref": "#/$defs/Empty", + "description": "The reply to `live`." + } + ], + "description": "The `ok` payload. Its shape follows the verb it answers." + }, + "Patch": { + "additionalProperties": true, + "description": "The payload of `set`: leaf paths, nested (`{\"zoom\":{\"factor\":2}}`) or dotted\n(`{\"zoom.factor\":2}`). [`crate::validate::leaves`] turns it into leaves.", + "type": "object" + }, + "Reply": { + "description": "A reply: `{\"id\":…,\"ok\":{…}}` or `{\"id\":…,\"error\":{…}}`.", + "oneOf": [ + { + "properties": { + "ok": { + "$ref": "#/$defs/OkBody" + } + }, + "required": [ + "ok" + ], + "type": "object" + }, + { + "properties": { + "error": { + "$ref": "#/$defs/ErrorBody" + } + }, + "required": [ + "error" + ], + "type": "object" + } + ], + "properties": { + "id": { + "anyOf": [ + { + "$ref": "#/$defs/RequestId" + }, + { + "type": "null" + } + ], + "description": "The request's id; `null` only for a `protocol` or `invalid` error without a usable id." + } + }, + "type": "object" + }, + "Request": { + "description": "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`.", + "properties": { + "device": { + "type": [ + "string", + "null" + ] + }, + "do": { + "type": [ + "string", + "null" + ] + }, + "get": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "hello": { + "anyOf": [ + { + "$ref": "#/$defs/Hello" + }, + { + "type": "null" + } + ] + }, + "id": { + "anyOf": [ + { + "$ref": "#/$defs/RequestId" + }, + { + "type": "null" + } + ], + "default": null + }, + "live": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "set": { + "anyOf": [ + { + "$ref": "#/$defs/Patch" + }, + { + "type": "null" + } + ] + }, + "with": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + } + }, + "type": "object" + }, + "RequestId": { + "anyOf": [ + { + "format": "int64", + "type": "integer" + }, + { + "type": "string" + } + ], + "description": "The `id` of a request: an integer or a string, echoed verbatim in its reply." + }, + "Requirement": { + "description": "A condition on another path, checked against the current state with the patch applied.", + "properties": { + "equals": true, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "equals" + ], + "type": "object" + }, + "RevOk": { + "properties": { + "result": { + "additionalProperties": true, + "description": "An action's result, when its description declares one.", + "type": [ + "object", + "null" + ] + }, + "rev": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "rev" + ], + "type": "object" + }, + "RevState": { + "properties": { + "rev": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "state": { + "$ref": "#/$defs/State" + } + }, + "required": [ + "rev", + "state" + ], + "type": "object" + }, + "ServerMessage": { + "anyOf": [ + { + "$ref": "#/$defs/Event" + }, + { + "$ref": "#/$defs/Reply" + } + ], + "description": "One line from the daemon: an event (it has `event`) or a reply." + }, + "Setting": { + "description": "One leaf of the state, or one action parameter or result field (4.2).", + "properties": { + "ascii": { + "description": "string: printable ASCII only (0x20 to 0x7e).", + "type": "boolean" + }, + "asleep": { + "anyOf": [ + { + "$ref": "#/$defs/Asleep" + }, + { + "type": "null" + } + ], + "description": "Present on every writable leaf; absent on read-only leaves and parameters." + }, + "default": { + "description": "The camera's own default as it reports it (UVC)." + }, + "item": { + "additionalProperties": { + "$ref": "#/$defs/Setting" + }, + "description": "list: the fields of one item.", + "type": [ + "object", + "null" + ] + }, + "label": { + "type": "string" + }, + "live": { + "description": "The live topic of a volatile leaf.", + "type": [ + "string", + "null" + ] + }, + "max": { + "anyOf": [ + { + "$ref": "#/$defs/Bound" + }, + { + "type": "null" + } + ] + }, + "max_len": { + "description": "string: the maximum length in characters.", + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "min": { + "anyOf": [ + { + "$ref": "#/$defs/Bound" + }, + { + "type": "null" + } + ] + }, + "no_effect": { + "default": false, + "type": "boolean" + }, + "nullable": { + "default": false, + "type": "boolean" + }, + "optional": { + "description": "Action parameters only: the parameter may be left out.", + "type": "boolean" + }, + "precision": { + "anyOf": [ + { + "$ref": "#/$defs/Bound" + }, + { + "type": "null" + } + ] + }, + "readback": { + "default": true, + "description": "`false`: the camera accepts the setting but cannot report it.", + "type": "boolean" + }, + "readonly": { + "default": false, + "type": "boolean" + }, + "requires": { + "items": { + "$ref": "#/$defs/Requirement" + }, + "type": "array" + }, + "settle_ms": { + "description": "The longest time the readback may lag a write of this leaf (4.1 rule 4).", + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "step": { + "anyOf": [ + { + "$ref": "#/$defs/Bound" + }, + { + "type": "null" + } + ] + }, + "type": { + "$ref": "#/$defs/SettingType" + }, + "unit": { + "anyOf": [ + { + "$ref": "#/$defs/UnitSpec" + }, + { + "type": "null" + } + ] + }, + "values": { + "description": "enum: every value a state may show.", + "items": { + "type": "string" + }, + "type": "array" + }, + "warning": { + "items": { + "$ref": "#/$defs/Warning" + }, + "type": "array" + }, + "writable": { + "description": "enum: the values a `set` accepts. Present on every writable enum, absent on read-only.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "type", + "label" + ], + "type": "object" + }, + "SettingType": { + "anyOf": [ + { + "enum": [ + "bool", + "enum", + "number", + "string" + ], + "type": "string" + }, + { + "const": "range", + "description": "`{min, max}`", + "type": "string" + }, + { + "const": "pose", + "description": "`{pan, tilt, zoom}`", + "type": "string" + }, + { + "const": "point", + "description": "`{x, y}`", + "type": "string" + }, + { + "const": "box", + "description": "`{left, top, right, bottom}`", + "type": "string" + }, + { + "const": "list", + "description": "A read-only list of objects; `item` describes their fields.", + "type": "string" + }, + { + "type": "string" + } + ] + }, + "State": { + "additionalProperties": true, + "description": "The published state of one device.", + "properties": { + "available": { + "type": "boolean" + }, + "pending": { + "additionalProperties": true, + "default": {}, + "description": "Leaf path → the value written; the camera accepted it but does not show it yet.", + "type": "object" + }, + "unread": { + "default": [], + "description": "Leaf paths never read since the device last became available, or whose last read failed.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "available" + ], + "type": "object" + }, + "Support": { + "anyOf": [ + { + "enum": [ + "tested", + "untested", + "unsupported" + ], + "type": "string" + }, + { + "description": "A level this crate does not know (a newer daemon).", + "type": "string" + } + ] + }, + "Unit": { + "anyOf": [ + { + "enum": [ + "deg", + "deg/s", + "factor", + "us", + "K", + "percent", + "s", + "fraction", + "fps" + ], + "type": "string" + }, + { + "type": "string" + } + ], + "description": "The closed unit list of 4.2; absent for a raw camera value. New units are additive." + }, + "UnitSpec": { + "anyOf": [ + { + "$ref": "#/$defs/Unit" + }, + { + "additionalProperties": { + "$ref": "#/$defs/Unit" + }, + "type": "object" + } + ], + "description": "A unit: one for the whole value, or one per component (a `pose` has degrees and a factor)." + }, + "Usb": { + "description": "The USB facts of a device, read from sysfs at enumeration (4.2). Never holds the serial.", + "properties": { + "bcd_device": { + "type": "string" + }, + "extension_units": { + "items": { + "$ref": "#/$defs/ExtensionUnit" + }, + "type": "array" + }, + "interfaces": { + "description": "Interface classes in interface order, two lower-case hex digits (`\"0e\"`, `\"01\"`).", + "items": { + "type": "string" + }, + "type": "array" + }, + "path": { + "description": "The USB device name in sysfs (`\"3-2.1\"`).", + "type": "string" + }, + "product": { + "type": "string" + }, + "product_name": { + "type": [ + "string", + "null" + ] + }, + "serial_present": { + "type": "boolean" + }, + "vendor": { + "description": "Four lower-case hex digits (`\"3564\"`).", + "type": "string" + } + }, + "required": [ + "vendor", + "product", + "bcd_device", + "path", + "serial_present", + "interfaces", + "extension_units" + ], + "type": "object" + }, + "Warning": { + "anyOf": [ + { + "enum": [ + "restart", + "destructive" + ], + "type": "string" + }, + { + "type": "string" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "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": [ + { + "$ref": "#/$defs/Request" + }, + { + "$ref": "#/$defs/ServerMessage" + } + ], + "title": "obsbot4linux protocol 2" +} From 9be99f9c9d5f463dd3301c1a457e3c8e53470697 Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 18:01:41 +0100 Subject: [PATCH 4/7] obsbot-proto: golden transcripts for hello, events, errors, hotplug and hand-over Adds tests/golden/session.jsonl, errors.jsonl and hotplug.jsonl ("> " client lines, "< " daemon lines, "#" comments) and tests/transcripts.rs. The test round-trips every line byte for byte, checks that errors.jsonl holds the 12 error codes in the order of 4.1, that revs only go up per entry and a state event names a listed device, that the hand-over is devices with replaced_by, devices without the old entry, then state, and that no golden file names a product id other than fef9 or the synthetic fa0x ids. Co-Authored-By: Claude Sonnet 5.5 --- crates/obsbot-proto/tests/golden/errors.jsonl | 13 ++ .../obsbot-proto/tests/golden/hotplug.jsonl | 16 ++ .../obsbot-proto/tests/golden/session.jsonl | 23 +++ crates/obsbot-proto/tests/transcripts.rs | 188 ++++++++++++++++++ 4 files changed, 240 insertions(+) create mode 100644 crates/obsbot-proto/tests/golden/errors.jsonl create mode 100644 crates/obsbot-proto/tests/golden/hotplug.jsonl create mode 100644 crates/obsbot-proto/tests/golden/session.jsonl create mode 100644 crates/obsbot-proto/tests/transcripts.rs 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/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"); +} From eca8d306e3d9ddc4e218c9feb8ecc122c9eb6f19 Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 18:01:27 +0100 Subject: [PATCH 5/7] obsbot-device: trait Camera, trait Transport, trait Bus and the registry types Adds obsbot_device::contract (design spec 8.2, 10.3 W0, plan task 5): camera.rs (Camera, CameraError, Tier, Group, LiveTier, Need, Identity, Target, Ctx, Motion, Frame, SendEntry, SendTable), transport.rs (Transport, ControlRange, OnAir), bus.rs (UsbId, IdentitySource, ModelEntry, Registry, Lookup, Found, Bus) and the conformance stub that task 7 fills. The carried code is untouched. Fixes #176 Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 1 + crates/obsbot-device/Cargo.toml | 1 + crates/obsbot-device/src/contract/bus.rs | 129 ++++++++ crates/obsbot-device/src/contract/camera.rs | 301 ++++++++++++++++++ .../obsbot-device/src/contract/conformance.rs | 3 + crates/obsbot-device/src/contract/mod.rs | 17 + .../obsbot-device/src/contract/transport.rs | 48 +++ crates/obsbot-device/src/lib.rs | 1 + crates/obsbot-device/tests/contract.rs | 191 +++++++++++ 9 files changed, 692 insertions(+) create mode 100644 crates/obsbot-device/src/contract/bus.rs create mode 100644 crates/obsbot-device/src/contract/camera.rs create mode 100644 crates/obsbot-device/src/contract/conformance.rs create mode 100644 crates/obsbot-device/src/contract/mod.rs create mode 100644 crates/obsbot-device/src/contract/transport.rs create mode 100644 crates/obsbot-device/tests/contract.rs diff --git a/Cargo.lock b/Cargo.lock index 84b3119..655067b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,6 +177,7 @@ dependencies = [ "clap", "hex", "nix", + "obsbot-proto", "serde", "serde_json", "thiserror", 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/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/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 + })); +} From 8e096578d1bb43f26390f771bb8594044e70323e Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 18:02:19 +0100 Subject: [PATCH 6/7] Remove the 0.13 command line from rebuild; CLI stub and dependency check Deletes the 0.13 cli, client, daemon and proto modules of the obsbot4linux crate, the CLI surface guard of stage G (tests/cli_surface.rs and its fixture) and legacy/full_status.rs of obsbot-device. legacy/state.rs and legacy/current_pose.rs stay. main.rs becomes a stub that prints a message and exits 1; the only dependency of the CLI crate is obsbot-proto. scripts/check-cli-deps.sh reads cargo metadata and fails when the CLI's [dependencies] contain obsbot-device or obsbot4linuxd (spec section 3); the test job of ci.yml runs it. The scripted test double loses Answer::Rejected: only the removed full_status tests constructed it. Fixes #177 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 1 + Cargo.lock | 6 +- .../obsbot-device/src/legacy/full_status.rs | 549 -------- crates/obsbot-device/src/legacy/mod.rs | 1 - crates/obsbot-device/src/scripted.rs | 3 - crates/obsbot4linux/Cargo.toml | 6 +- crates/obsbot4linux/src/cli.rs | 1218 ----------------- crates/obsbot4linux/src/client.rs | 145 -- crates/obsbot4linux/src/daemon.rs | 754 ---------- crates/obsbot4linux/src/main.rs | 13 +- crates/obsbot4linux/src/proto.rs | 223 --- crates/obsbot4linux/tests/cli_surface.rs | 75 - .../tests/fixtures/cli-surface.txt | 1126 --------------- scripts/check-cli-deps.sh | 15 + 14 files changed, 24 insertions(+), 4111 deletions(-) delete mode 100644 crates/obsbot-device/src/legacy/full_status.rs delete mode 100644 crates/obsbot4linux/src/cli.rs delete mode 100644 crates/obsbot4linux/src/client.rs delete mode 100644 crates/obsbot4linux/src/daemon.rs delete mode 100644 crates/obsbot4linux/src/proto.rs delete mode 100644 crates/obsbot4linux/tests/cli_surface.rs delete mode 100644 crates/obsbot4linux/tests/fixtures/cli-surface.txt create mode 100755 scripts/check-cli-deps.sh 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 655067b..602045a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,11 +196,7 @@ dependencies = [ name = "obsbot4linux" version = "0.13.2" dependencies = [ - "clap", - "nix", - "obsbot-device", - "serde", - "serde_json", + "obsbot-proto", ] [[package]] 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..9128a5b 100644 --- a/crates/obsbot-device/src/legacy/mod.rs +++ b/crates/obsbot-device/src/legacy/mod.rs @@ -4,5 +4,4 @@ //! carry it over (spec section 3.2); the 0.13 daemon and CLI use it until they are replaced. pub mod current_pose; -pub mod full_status; pub mod state; diff --git a/crates/obsbot-device/src/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/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