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