From 730406034ab5a88984715767ed25567f56fca216 Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 17:54:59 +0100 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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 + })); +}