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..3b01502 --- /dev/null +++ b/crates/obsbot-proto/src/schema.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The JSON schema of protocol 2, `schema/protocol-2.json` (design spec, sections 3 and 4). +//! It covers every line on the socket (requests, replies, events) and, through `Device`, the +//! description format. It is generated from the types of this crate and checked for staleness +//! by `tests/schema.rs`. + +use crate::description::Description; +use crate::message::{Request, ServerMessage}; +use schemars::generate::SchemaSettings; +use serde_json::{Value, json}; + +/// The schema as a JSON value. +pub fn protocol_schema() -> Value { + let mut generator = SchemaSettings::draft2020_12().into_generator(); + let request = generator.subschema_for::(); + let server = generator.subschema_for::(); + generator.subschema_for::(); + let defs = generator.take_definitions(true); + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "obsbot4linux protocol 2", + "description": "One JSON object per line on the socket: a Request from a client, or a ServerMessage (a Reply or an Event) from the daemon. Description is the content of descriptions/.json.", + "oneOf": [request, server], + "$defs": defs, + }) +} + +/// The file content: pretty JSON with a final newline. +pub fn schema_text() -> String { + let mut text = + serde_json::to_string_pretty(&protocol_schema()).expect("a JSON value always serialises"); + text.push('\n'); + text +} diff --git a/crates/obsbot-proto/src/state.rs b/crates/obsbot-proto/src/state.rs new file mode 100644 index 0000000..0f2ed84 --- /dev/null +++ b/crates/obsbot-proto/src/state.rs @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The generic payload: state, patches, action parameters and path helpers (4.1, 4.2, 6.1). +//! +//! A state is a nested JSON object in the shape of the model's state tree (section 4.3 for +//! the Tiny 2 Lite), plus the daemon's fields `available`, `pending` (leaf path → written +//! value) and `unread` (leaf paths). A leaf path joins the object keys with `.`. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; + +/// Action parameters and action results: name → value. +pub type Params = Map; + +/// Leaf path → value, as a model reads one group. +pub type Values = BTreeMap; + +/// The paths the daemon adds to every state; they are never settings. +pub const DAEMON_PATHS: [&str; 3] = ["available", "pending", "unread"]; + +/// The published state of one device. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct State { + pub available: bool, + /// Leaf path → the value written; the camera accepted it but does not show it yet. + #[serde(default)] + pub pending: BTreeMap, + /// Leaf paths never read since the device last became available, or whose last read failed. + #[serde(default)] + pub unread: Vec, + /// The state tree: group → … → leaf value. + #[serde(flatten)] + pub values: Map, +} + +impl State { + /// An empty state: no values, nothing pending, nothing unread. + pub fn new(available: bool) -> State { + State { + available, + pending: BTreeMap::new(), + unread: Vec::new(), + values: Map::new(), + } + } + + /// The value at a leaf or group path of the state tree. `None` when the path is absent. + /// The daemon's fields are struct fields, not paths of the tree. + pub fn at(&self, path: &str) -> Option<&Value> { + let mut parts = path.split('.'); + let mut node = self.values.get(parts.next()?)?; + for part in parts { + node = node.as_object()?.get(part)?; + } + Some(node) + } + + /// Sets the value at a leaf path, creating the groups on the way. + pub fn set(&mut self, path: &str, value: Value) { + let parts: Vec<&str> = path.split('.').collect(); + let (last, groups) = parts.split_last().expect("split always yields one part"); + let mut map = &mut self.values; + for group in groups { + let entry = map + .entry(group.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if !entry.is_object() { + *entry = Value::Object(Map::new()); + } + map = entry.as_object_mut().expect("made an object above"); + } + map.insert(last.to_string(), value); + } + + /// A copy with the given leaves set: the state a `requires` check sees (4.2). + pub fn with(&self, leaves: &[(String, Value)]) -> State { + let mut next = self.clone(); + for (path, value) in leaves { + next.set(path, value.clone()); + } + next + } + + /// The reply of a `get` for these paths (4.1 rule 9): the paths asked for, plus + /// `available`, `pending` and `unread` in full. Absent paths are left out. + pub fn select(&self, paths: &[String]) -> State { + let mut out = State { + available: self.available, + pending: self.pending.clone(), + unread: self.unread.clone(), + values: Map::new(), + }; + for path in paths { + if DAEMON_PATHS.contains(&path.as_str()) { + continue; + } + if let Some(value) = self.at(path) { + out.set(path, value.clone()); + } + } + out + } + + /// Whether the state shows the camera asleep (`asleep: true`). + pub fn asleep(&self) -> bool { + self.at("asleep") == Some(&Value::Bool(true)) + } +} + +/// The payload of `set`: leaf paths, nested (`{"zoom":{"factor":2}}`) or dotted +/// (`{"zoom.factor":2}`). [`crate::validate::leaves`] turns it into leaves. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct Patch(pub Map); + +impl Patch { + pub fn new() -> Patch { + Patch(Map::new()) + } + + /// Adds one leaf under its dotted path. + pub fn leaf(mut self, path: &str, value: impl Into) -> Patch { + self.0.insert(path.to_string(), value.into()); + self + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample() -> State { + let mut s = State::new(true); + s.set("zoom.factor", json!(1.5)); + s.set("zone.area.pan", json!({"min": -20, "max": 20})); + s.set("asleep", json!(false)); + s.pending.insert("zoom.factor".into(), json!(2)); + s.unread.push("presets".into()); + s + } + + #[test] + fn paths_walk_the_nested_tree() { + let s = sample(); + assert_eq!(s.at("zoom.factor"), Some(&json!(1.5))); + assert_eq!( + s.at("zone.area"), + Some(&json!({"pan": {"min": -20, "max": 20}})) + ); + assert_eq!( + s.at("zone.area.pan.min"), + Some(&json!(-20)), + "a component is reachable too" + ); + assert_eq!( + s.at("gimbal.pan"), + None, + "absent paths are None, never null" + ); + assert!(!s.asleep()); + } + + #[test] + fn a_get_reply_keeps_the_daemon_fields_in_full() { + let got = sample().select(&["zoom".to_string(), "nothing.here".to_string()]); + assert_eq!(got.at("zoom.factor"), Some(&json!(1.5))); + assert_eq!(got.at("zone.area.pan"), None, "only the paths asked for"); + assert_eq!(got.pending.get("zoom.factor"), Some(&json!(2))); + assert_eq!(got.unread, vec!["presets".to_string()]); + } + + #[test] + fn with_applies_leaves_without_touching_the_original() { + let s = sample(); + let next = s.with(&[("exposure.mode".into(), json!("manual"))]); + assert_eq!(next.at("exposure.mode"), Some(&json!("manual"))); + assert_eq!(s.at("exposure.mode"), None); + } + + #[test] + fn a_patch_built_with_leaf_uses_dotted_keys() { + let p = Patch::new() + .leaf("led.brightness", 2) + .leaf("zoom.factor", 1.5); + assert_eq!( + serde_json::to_string(&p).unwrap(), + r#"{"led.brightness":2,"zoom.factor":1.5}"# + ); + } +} diff --git a/crates/obsbot-proto/src/validate.rs b/crates/obsbot-proto/src/validate.rs new file mode 100644 index 0000000..6c0c609 --- /dev/null +++ b/crates/obsbot-proto/src/validate.rs @@ -0,0 +1,635 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The validator (4.1 rule 5, 4.2, 5.6): checks a `set` patch, a `do` and a `get` against a +//! device description and its current state, before anything is sent to the camera. The +//! daemon is authoritative; clients may run the same checks. +//! +//! The whole request is checked. When several things are wrong, the refusal with the first +//! code in this order wins, and within one code the first path in path order: +//! `invalid`, `unsupported`, `out_of_range`, `conflict`, `asleep` (the order of 5.6). + +use crate::description::{Asleep, Bound, Description, Setting, SettingType, Warning}; +use crate::message::ErrorCode; +use crate::state::{DAEMON_PATHS, Params, Patch, State}; +use crate::values::{is_raw, same}; +use serde_json::Value; +use std::collections::BTreeSet; + +/// Why a request is refused. `path` names the leaf, parameter or action at fault. +#[derive(Debug, Clone, PartialEq)] +pub struct Refusal { + pub code: ErrorCode, + pub path: Option, + pub message: String, +} + +impl Refusal { + pub fn new(code: ErrorCode, path: &str, message: &str) -> Refusal { + Refusal { + code, + path: Some(path.to_string()), + message: message.to_string(), + } + } +} + +/// What a value is checked for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Fit { + /// A value a client writes: `writable[]`, bounds, step; never `null` or `raw:`. + Write, + /// A value a state may show: `values[]`, bounds; `null` where `nullable`; `raw:`. + State, +} + +fn rank(code: &ErrorCode) -> u8 { + match code { + ErrorCode::Invalid => 0, + ErrorCode::Unsupported => 1, + ErrorCode::OutOfRange => 2, + ErrorCode::Conflict => 3, + ErrorCode::Asleep => 4, + _ => 5, + } +} + +/// Keeps the refusal that wins under the order above. +#[derive(Default)] +struct First(Option); + +impl First { + fn add(&mut self, code: ErrorCode, path: &str, message: String) { + let better = match &self.0 { + None => true, + Some(old) => rank(&code) < rank(&old.code), + }; + if better { + let path = (!path.is_empty()).then(|| path.to_string()); + self.0 = Some(Refusal { + code, + path, + message, + }); + } + } + + fn done(self) -> Result<(), Refusal> { + self.0.map_or(Ok(()), Err) + } +} + +/// The leaves of a patch, nested or dotted, as (leaf path, value) in path order. Refuses an +/// empty patch, a group written as a value, a leaf named twice (`invalid`) and a path that is +/// not in the description (`unsupported`). +pub fn leaves(desc: &Description, patch: &Patch) -> Result, Refusal> { + let (out, first) = split(desc, patch); + first.done()?; + Ok(out) +} + +/// The leaves of a patch and the first structural problem, if any. +fn split(desc: &Description, patch: &Patch) -> (Vec<(String, Value)>, First) { + let mut first = First::default(); + let mut out: Vec<(String, Value)> = Vec::new(); + if patch.is_empty() { + first.add(ErrorCode::Invalid, "", "the patch is empty".into()); + } + collect(desc, "", &patch.0, &mut out, &mut first); + out.sort_by(|a, b| a.0.cmp(&b.0)); + for pair in out.windows(2) { + if pair[0].0 == pair[1].0 { + first.add( + ErrorCode::Invalid, + &pair[0].0, + format!("the patch names {} twice", pair[0].0), + ); + } + } + out.dedup_by(|a, b| a.0 == b.0); + (out, first) +} + +fn collect( + desc: &Description, + prefix: &str, + map: &serde_json::Map, + out: &mut Vec<(String, Value)>, + first: &mut First, +) { + for (key, value) in map { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + if desc.settings.contains_key(&path) { + out.push((path, value.clone())); + } else if desc.is_group(&path) { + match value.as_object() { + Some(inner) => collect(desc, &path, inner, out, first), + None => first.add( + ErrorCode::Invalid, + &path, + format!("{path} is a group, not a leaf"), + ), + } + } else { + first.add( + ErrorCode::Unsupported, + &path, + format!("{path} is not a setting of this device"), + ); + } + } +} + +/// Checks a `set` against the description and the current state; returns its leaves in path +/// order. The model's apply order is `Camera::order`, not this order. +pub fn validate_set( + desc: &Description, + state: &State, + patch: &Patch, +) -> Result, Refusal> { + let (leaves, mut first) = split(desc, patch); + for (path, value) in &leaves { + let setting = &desc.settings[path]; + if setting.readonly { + first.add(ErrorCode::Invalid, path, format!("{path} is read-only")); + } else if let Err((code, message)) = check_value(setting, value, Fit::Write) { + first.add(code, path, format!("{path}: {message}")); + } + } + let next = state.with(&leaves); + for (path, _) in &leaves { + let setting = &desc.settings[path]; + for req in &setting.requires { + if !next.at(&req.path).is_some_and(|v| same(v, &req.equals)) { + first.add( + ErrorCode::Conflict, + path, + format!("{path} requires {} = {}", req.path, req.equals), + ); + } + } + if state.asleep() && setting.asleep == Some(Asleep::Ignored) { + first.add( + ErrorCode::Asleep, + path, + format!("the camera is asleep and ignores {path}"), + ); + } + } + first.done()?; + Ok(leaves) +} + +/// Checks a `do` against the description and the current state: the action exists, every +/// parameter is known and fits, no required parameter is missing, a destructive action has +/// `confirm: true`, its `requires` hold and the camera is not asleep for an `ignored` action. +pub fn validate_do( + desc: &Description, + state: &State, + action: &str, + params: &Params, +) -> Result<(), Refusal> { + let Some(spec) = desc.actions.get(action) else { + return Err(Refusal::new( + ErrorCode::Unsupported, + action, + &format!("{action} is not an action of this device"), + )); + }; + let mut first = First::default(); + for (name, value) in params { + match spec.params.get(name) { + None => first.add( + ErrorCode::Invalid, + name, + format!("{action} has no parameter {name}"), + ), + Some(setting) => { + if let Err((code, message)) = check_value(setting, value, Fit::Write) { + first.add(code, name, format!("{action} {name}: {message}")); + } + } + } + } + for (name, setting) in &spec.params { + if !setting.optional && !params.contains_key(name) { + first.add( + ErrorCode::Invalid, + name, + format!("{action} needs the parameter {name}"), + ); + } + } + if spec.warning.contains(&Warning::Destructive) + && params.get("confirm") != Some(&Value::Bool(true)) + { + first.add( + ErrorCode::Invalid, + "confirm", + format!("{action} deletes data on the camera: send confirm: true"), + ); + } + for req in &spec.requires { + if !state.at(&req.path).is_some_and(|v| same(v, &req.equals)) { + first.add( + ErrorCode::Conflict, + action, + format!("{action} requires {} = {}", req.path, req.equals), + ); + } + } + if state.asleep() && spec.asleep == Asleep::Ignored { + first.add( + ErrorCode::Asleep, + action, + format!("the camera is asleep and ignores {action}"), + ); + } + first.done() +} + +/// Checks the paths of a `get`: each is a daemon field, a leaf or a group of the description. +pub fn validate_get(desc: &Description, paths: &[String]) -> Result<(), Refusal> { + let mut first = First::default(); + for path in paths { + let known = DAEMON_PATHS.contains(&path.as_str()) + || desc.settings.contains_key(path) + || desc.is_group(path); + if !known { + first.add( + ErrorCode::Unsupported, + path, + format!("{path} is not a path of this device"), + ); + } + } + first.done() +} + +fn as_number(v: &Value) -> Option { + v.as_f64().filter(|x| x.is_finite()) +} + +const EPS: f64 = 1e-9; + +fn check_number( + s: &Setting, + x: f64, + component: Option<&str>, + fit: Fit, +) -> Result<(), (ErrorCode, String)> { + let bound = |b: &Option| b.as_ref().and_then(|b| b.get(component)); + let what = component.map_or(String::new(), |c| format!("{c} ")); + if let Some(min) = bound(&s.min) + && x < min - EPS + { + return Err(( + ErrorCode::OutOfRange, + format!("{what}{x} is below the minimum {min}"), + )); + } + if let Some(max) = bound(&s.max) + && x > max + EPS + { + return Err(( + ErrorCode::OutOfRange, + format!("{what}{x} is above the maximum {max}"), + )); + } + if fit == Fit::Write + && let Some(step) = bound(&s.step).filter(|st| *st > 0.0) + { + let k = (x - bound(&s.min).unwrap_or(0.0)) / step; + if (k - k.round()).abs() > 1e-6 { + return Err(( + ErrorCode::OutOfRange, + format!("{what}{x} is not a multiple of the step {step}"), + )); + } + } + Ok(()) +} + +/// Checks one value against one setting. The error carries the code and an English message +/// without the path. +pub fn check_value(s: &Setting, v: &Value, fit: Fit) -> Result<(), (ErrorCode, String)> { + if v.is_null() { + return if fit == Fit::State && s.nullable { + Ok(()) + } else { + Err(( + ErrorCode::Invalid, + "null is not a value of this setting".into(), + )) + }; + } + if fit == Fit::State && v.as_str().is_some_and(is_raw) { + return Ok(()); + } + let invalid = |m: &str| Err((ErrorCode::Invalid, m.to_string())); + match &s.kind { + SettingType::Bool => { + if v.is_boolean() { + Ok(()) + } else { + invalid("expected true or false") + } + } + SettingType::Enum => { + let Some(text) = v.as_str() else { + return invalid("expected one of the names of this setting"); + }; + let allowed = if fit == Fit::Write { + &s.writable + } else { + &s.values + }; + if allowed.iter().any(|a| a == text) { + Ok(()) + } else { + Err(( + ErrorCode::OutOfRange, + format!("{text} is not one of {}", allowed.join("|")), + )) + } + } + SettingType::Number => match as_number(v) { + Some(x) => check_number(s, x, None, fit), + None => invalid("expected a number"), + }, + SettingType::String => { + let Some(text) = v.as_str() else { + return invalid("expected a string"); + }; + if let Some(max) = s.max_len + && text.chars().count() > max as usize + { + return Err(( + ErrorCode::OutOfRange, + format!("longer than {max} characters"), + )); + } + if s.ascii && !text.bytes().all(|b| (0x20..=0x7e).contains(&b)) { + return Err(( + ErrorCode::OutOfRange, + "only printable ASCII characters are allowed".into(), + )); + } + Ok(()) + } + SettingType::List => { + if fit == Fit::Write { + return invalid("a list is read-only"); + } + let Some(items) = v.as_array() else { + return invalid("expected a list"); + }; + let fields = s.item.clone().unwrap_or_default(); + for item in items { + let Some(obj) = item.as_object() else { + return invalid("expected a list of objects"); + }; + for (name, field) in &fields { + let value = obj.get(name).unwrap_or(&Value::Null); + check_value(field, value, Fit::State) + .map_err(|(c, m)| (c, format!("item {name}: {m}")))?; + } + } + Ok(()) + } + SettingType::Other(kind) => { + if fit == Fit::State { + Ok(()) + } else { + invalid(&format!("a setting of type {kind} cannot be written")) + } + } + kind => { + let names = kind + .components() + .expect("the remaining types are object-shaped"); + let Some(obj) = v.as_object() else { + return invalid(&format!("expected an object with {}", names.join(", "))); + }; + let keys: BTreeSet<&str> = obj.keys().map(String::as_str).collect(); + if keys != names.iter().copied().collect() { + return invalid(&format!("expected exactly the fields {}", names.join(", "))); + } + for name in names { + let Some(x) = as_number(&obj[*name]) else { + return invalid(&format!("{name} must be a number")); + }; + check_number(s, x, Some(name), fit)?; + } + if *kind == SettingType::Range && as_number(&obj["min"]) > as_number(&obj["max"]) { + return Err((ErrorCode::OutOfRange, "min is above max".into())); + } + Ok(()) + } + } +} + +fn path_ok(path: &str) -> bool { + !path.is_empty() + && path.split('.').all(|seg| { + let mut bytes = seg.bytes(); + bytes.next().is_some_and(|b| b.is_ascii_lowercase()) + && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') + }) +} + +fn check_setting( + where_: &str, + s: &Setting, + desc: &Description, + leaf: bool, + problems: &mut Vec, +) { + let mut bad = |m: &str| problems.push(format!("{where_}: {m}")); + if s.label.is_empty() { + bad("has no label"); + } + match &s.kind { + SettingType::Enum => { + if s.values.is_empty() { + bad("an enum without values"); + } + if s.writable.iter().any(|w| !s.values.contains(w)) { + bad("writable holds a value that values does not"); + } + if leaf && s.readonly && !s.writable.is_empty() { + bad("a read-only enum with writable values"); + } + if (!leaf || !s.readonly) && s.writable.is_empty() { + bad("a writable enum without writable values"); + } + } + SettingType::Number => { + for (name, b) in [ + ("min", &s.min), + ("max", &s.max), + ("step", &s.step), + ("precision", &s.precision), + ] { + if matches!(b, Some(Bound::Each(_))) { + bad(&format!("{name} of a number must be one number")); + } + } + } + SettingType::List => { + if !s.readonly || s.item.is_none() { + bad("a list must be read-only and describe its item"); + } + } + kind => { + if let Some(names) = kind.components() { + for (name, b) in [("min", &s.min), ("max", &s.max)] { + if let Some(Bound::Each(map)) = b { + let keys: BTreeSet<&str> = map.keys().map(String::as_str).collect(); + if keys != names.iter().copied().collect() { + bad(&format!( + "{name} must have exactly the fields {}", + names.join(", ") + )); + } + } else if b.is_some() { + bad(&format!( + "{name} of an object-shaped setting must be an object" + )); + } + } + } + } + } + let components: Vec> = match s.kind.components() { + Some(names) => names.iter().map(|n| Some(*n)).collect(), + None => vec![None], + }; + for c in components { + if let (Some(lo), Some(hi)) = ( + s.min.as_ref().and_then(|b| b.get(c)), + s.max.as_ref().and_then(|b| b.get(c)), + ) && lo > hi + { + bad("min is above max"); + } + } + if let Some(default) = &s.default + && check_value(s, default, Fit::State).is_err() + { + bad("the default does not fit the setting"); + } + for req in &s.requires { + match desc.settings.get(&req.path) { + None => bad(&format!("requires the unknown path {}", req.path)), + Some(target) => { + if check_value(target, &req.equals, Fit::State).is_err() { + bad(&format!("requires a value that {} cannot have", req.path)); + } + } + } + } + if let Some(topic) = &s.live + && !path_ok(topic) + { + bad("the live topic is not a lower-case name"); + } + if leaf { + if s.optional { + bad("optional is for action parameters only"); + } + if s.readonly && (s.asleep.is_some() || s.settle_ms.is_some()) { + bad("a read-only leaf has neither asleep nor settle_ms"); + } + if !s.readonly && s.asleep.is_none() { + bad("a writable leaf needs asleep"); + } + if !s.readonly && s.readback && s.settle_ms.is_none() { + bad("a writable leaf with a readback needs settle_ms"); + } + if !s.readback && s.settle_ms.is_some() { + bad("a leaf without readback has no settle_ms"); + } + } +} + +/// Checks that a description is consistent in itself; returns the problems, empty when there +/// are none. Every model and fake must pass (trait conformance, 10.3 W0). +pub fn check_description(desc: &Description) -> Vec { + let mut problems = Vec::new(); + for (path, setting) in &desc.settings { + if !path_ok(path) { + problems.push(format!( + "{path}: not lower-case snake_case segments joined by '.'" + )); + } + if DAEMON_PATHS.contains(&path.as_str()) { + problems.push(format!("{path}: reserved for the daemon")); + } + if desc.is_group(path) { + problems.push(format!("{path}: a leaf that is also a group")); + } + check_setting(path, setting, desc, true, &mut problems); + for (field, item) in setting.item.iter().flatten() { + check_setting( + &format!("{path}[].{field}"), + item, + desc, + false, + &mut problems, + ); + } + } + for (name, action) in &desc.actions { + if !path_ok(name) { + problems.push(format!( + "{name}: not lower-case snake_case segments joined by '.'" + )); + } + if action.label.is_empty() { + problems.push(format!("{name}: has no label")); + } + for (param, setting) in action.params.iter().chain(action.result.iter().flatten()) { + if !path_ok(param) || param.contains('.') { + problems.push(format!( + "{name}.{param}: a parameter name is one lower-case segment" + )); + } + check_setting( + &format!("{name}.{param}"), + setting, + desc, + false, + &mut problems, + ); + } + if action.warning.contains(&Warning::Destructive) + && action + .params + .get("confirm") + .is_none_or(|c| c.kind != SettingType::Bool) + { + problems.push(format!( + "{name}: a destructive action needs the bool parameter confirm" + )); + } + for req in &action.requires { + if !desc.settings.contains_key(&req.path) { + problems.push(format!("{name}: requires the unknown path {}", req.path)); + } + } + } + for list in desc.limits.keys() { + if desc + .settings + .get(list) + .is_none_or(|s| s.kind != SettingType::List) + { + problems.push(format!("limits.{list}: not a list setting")); + } + } + problems +} diff --git a/crates/obsbot-proto/src/values.rs b/crates/obsbot-proto/src/values.rs new file mode 100644 index 0000000..0e75df4 --- /dev/null +++ b/crates/obsbot-proto/src/values.rs @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Value helpers shared by the daemon, the models and the clients: canonical numbers, +//! rounding to a precision (4.2 "Precision"), `raw:` for undecodable values and value +//! equality that treats `1` and `1.0` as the same number. + +use serde_json::{Number, Value}; + +/// A JSON number in canonical form: an integral value within ±2^53 is written as an integer +/// (`3`, not `3.0`), anything else as a float. Non-finite input gives `null`. +pub fn number(x: f64) -> Value { + if !x.is_finite() { + return Value::Null; + } + if x.fract() == 0.0 && x.abs() < 9_007_199_254_740_992.0 { + return Value::Number(Number::from(x as i64)); + } + Number::from_f64(x).map_or(Value::Null, Value::Number) +} + +/// `x` rounded to `precision` (0.1, 0.01, 1, 100 …) in canonical form, so that state and +/// `--json` never show binary float noise. +pub fn rounded(x: f64, precision: f64) -> Value { + if precision <= 0.0 { + return number(x); + } + let steps = (x / precision).round(); + let decimals = (-precision.log10()).ceil().max(0.0) as i32; + let scale = 10f64.powi(decimals); + number((steps * precision * scale).round() / scale) +} + +/// The published form of a camera value the model cannot decode: `raw:` (4.2). +pub fn raw(n: i64) -> Value { + Value::String(format!("raw:{n}")) +} + +/// Whether a string is a `raw:` value. +pub fn is_raw(text: &str) -> bool { + text.strip_prefix("raw:").is_some_and(|n| { + !n.is_empty() + && n.trim_start_matches('-') + .bytes() + .all(|b| b.is_ascii_digit()) + }) +} + +/// Value equality in which numbers compare by value (`1` equals `1.0`), recursively. +pub fn same(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => x.as_f64() == y.as_f64(), + (Value::Array(x), Value::Array(y)) => { + x.len() == y.len() && x.iter().zip(y).all(|(p, q)| same(p, q)) + } + (Value::Object(x), Value::Object(y)) => { + x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same(v, w))) + } + _ => a == b, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn integral_numbers_are_written_without_a_fraction() { + assert_eq!(number(3.0).to_string(), "3"); + assert_eq!(number(-150.0).to_string(), "-150"); + assert_eq!(number(1.5).to_string(), "1.5"); + assert_eq!(number(f64::NAN), Value::Null); + } + + #[test] + fn rounding_removes_float_noise() { + assert_eq!(rounded(0.1 + 0.2, 0.1).to_string(), "0.3"); + assert_eq!(rounded(12.345_678, 0.01).to_string(), "12.35"); + assert_eq!(rounded(-30.04, 0.1).to_string(), "-30"); + assert_eq!(rounded(1234.0, 100.0).to_string(), "1200"); + } + + #[test] + fn raw_values_are_recognised() { + assert_eq!(raw(3), json!("raw:3")); + assert!(is_raw("raw:3")); + assert!(is_raw("raw:-1")); + assert!(!is_raw("raw:")); + assert!(!is_raw("raw:x")); + assert!(!is_raw("auto")); + } + + #[test] + fn numbers_compare_by_value() { + assert!(same(&json!(1), &json!(1.0))); + assert!(same( + &json!({"min": 1, "max": 2}), + &json!({"max": 2.0, "min": 1.0}) + )); + assert!(!same(&json!(1), &json!("1"))); + assert!(!same(&json!([1]), &json!([1, 2]))); + } +} diff --git a/crates/obsbot-proto/tests/golden/types.jsonl b/crates/obsbot-proto/tests/golden/types.jsonl new file mode 100644 index 0000000..8517274 --- /dev/null +++ b/crates/obsbot-proto/tests/golden/types.jsonl @@ -0,0 +1,65 @@ +# One line per case: the type name, a space, the canonical JSON of one value of that type. +# Every line must parse into its type and serialise back byte for byte (tests/types.rs). +RequestId 7 +RequestId "a-7" +Support "tested" +Support "untested" +Support "unsupported" +Support "beta" +Unit "deg/s" +Unit "K" +Unit "lux" +UnitSpec "fraction" +UnitSpec {"pan":"deg","tilt":"deg","zoom":"factor"} +Bound 0.01 +Bound {"max":150,"min":-150} +Asleep "ignored" +Asleep "sometimes" +Warning "destructive" +Warning "explode" +SettingType "box" +SettingType "matrix" +ErrorCode "out_of_range" +ErrorCode "rate_limited" +Requirement {"path":"exposure.mode","equals":"manual"} +ExtensionUnit {"unit":2,"guid":"00000000-0000-0000-0000-000000000001","controls":12} +Usb {"vendor":"3564","product":"fa02","product_name":null,"bcd_device":"0100","path":"3-2.1","serial_present":false,"interfaces":["0e","0e","01","01"],"extension_units":[{"unit":2,"guid":"00000000-0000-0000-0000-000000000001","controls":12}]} +Setting {"type":"bool","label":"Invert pan direction","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"settle_ms":3000} +Setting {"type":"enum","label":"Field of view","readonly":false,"nullable":false,"readback":true,"asleep":"ignored","no_effect":false,"values":["wide","medium","narrow","custom"],"writable":["wide","medium","narrow"],"settle_ms":3000} +Setting {"type":"number","label":"Exposure time","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"min":100,"max":250000,"unit":"us","precision":1,"requires":[{"path":"exposure.mode","equals":"manual"}],"default":33000,"live":"auto","settle_ms":3000} +Setting {"type":"range","label":"Zone pan","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"min":{"max":-150,"min":-150},"max":{"max":150,"min":150},"unit":"deg","precision":0.1,"settle_ms":3000} +Setting {"type":"pose","label":"Hand return pose","readonly":true,"nullable":true,"readback":true,"no_effect":false,"min":{"pan":-150,"tilt":-70,"zoom":1},"max":{"pan":150,"tilt":90,"zoom":4},"unit":{"pan":"deg","tilt":"deg","zoom":"factor"},"precision":{"pan":0.1,"tilt":0.1,"zoom":0.01}} +Setting {"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true,"optional":true} +Setting {"type":"list","label":"Presets","readonly":true,"nullable":false,"readback":true,"no_effect":false,"item":{"name":{"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true},"slot":{"type":"number","label":"Slot","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"max":2,"step":1}}} +Setting {"type":"bool","label":"LED blink","readonly":false,"nullable":false,"readback":false,"asleep":"unknown","no_effect":false} +Setting {"type":"bool","label":"Microphone","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"settle_ms":3000,"warning":["restart"]} +Setting {"type":"number","label":"Wake zoom","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":true,"min":1,"max":4,"unit":"factor","precision":0.01,"settle_ms":3000} +Setting {"type":"matrix","label":"Colour matrix","readonly":false,"nullable":false,"readback":true,"asleep":"unknown","no_effect":false,"settle_ms":3000} +Action {"label":"Stop the gimbal","params":{},"asleep":"unknown"} +Action {"label":"Save a view","params":{"name":{"type":"string","label":"Name","readonly":false,"nullable":false,"readback":true,"no_effect":false,"max_len":32,"ascii":true}},"result":{"id":{"type":"number","label":"View id","readonly":false,"nullable":false,"readback":true,"no_effect":false,"min":0,"step":1}},"asleep":"ignored"} +Action {"label":"Factory reset","params":{"confirm":{"type":"bool","label":"Confirm","readonly":false,"nullable":false,"readback":true,"no_effect":false}},"asleep":"unknown","warning":["destructive","restart"]} +Description {"settings":{"led.blink":{"type":"bool","label":"LED blink","readonly":false,"nullable":false,"readback":false,"asleep":"unknown","no_effect":false}},"actions":{"gimbal.stop":{"label":"Stop the gimbal","params":{},"asleep":"unknown"}},"limits":{"presets":3}} +Device {"id":"port:3-2.1","name":"USB 3564:fa02 3-2.1","model":"unknown","support":"unsupported","serial":null,"firmware":null,"video_node":"/dev/video4","available":true,"usb":{"vendor":"3564","product":"fa02","product_name":null,"bcd_device":"0100","path":"3-2.1","serial_present":false,"interfaces":["0e","0e"],"extension_units":[]},"settings":{},"actions":{},"limits":{}} +Device {"id":"port:3-2.2","name":"Example Camera 3-2.2","model":"example","support":"tested","serial":null,"firmware":null,"video_node":null,"available":false,"replaced_by":"sn:EXAMPLE0000001","usb":{"vendor":"3564","product":"fa01","product_name":"Example Camera","bcd_device":"0100","path":"3-2.2","serial_present":false,"interfaces":["0e"],"extension_units":[]},"settings":{},"actions":{},"limits":{}} +State {"available":true,"pending":{"led.brightness":2},"unread":["presets"],"asleep":false,"led":{"brightness":1},"presets":null,"zone":{"area":{"pan":{"max":20,"min":-20}}}} +Patch {"exposure":{"mode":"manual","time":20000},"led.brightness":2} +Hello {"protocol":2,"client":"widget 1.0","subscribe":true,"live":["pose"]} +Request {"id":1,"hello":{"protocol":2,"client":"obsbot4linux 1.0.0","subscribe":false,"live":[]}} +Request {"id":2,"device":"sn:EXAMPLE0000001","get":null} +Request {"id":3,"device":"sn:EXAMPLE0000001","get":["zoom.factor","presets"]} +Request {"id":4,"device":"sn:EXAMPLE0000001","set":{"zone":{"area":{"pan":{"max":20,"min":-20}}}}} +Request {"id":"v","device":"sn:EXAMPLE0000001","do":"view.save","with":{"name":"Desk","pose":{"pan":0,"tilt":5,"zoom":1.2}}} +Request {"id":6,"device":"sn:EXAMPLE0000001","do":"gimbal.stop"} +Request {"id":7,"live":["pose","target"]} +Reply {"id":1,"ok":{"protocol":2,"daemon":"1.0.0","devices":[],"states":{}}} +Reply {"id":2,"ok":{"rev":5,"state":{"available":true,"pending":{},"unread":[],"zoom":{"factor":1.5}}}} +Reply {"id":4,"ok":{"rev":6}} +Reply {"id":"v","ok":{"rev":7,"result":{"id":3}}} +Reply {"id":7,"ok":{}} +Reply {"id":8,"error":{"code":"camera","message":"the camera did not answer","rev":9,"applied":["exposure.mode"],"failed":"exposure.time"}} +Reply {"id":null,"error":{"code":"protocol","message":"the first message must be hello","supported":[2]}} +ErrorBody {"code":"out_of_range","message":"led.brightness: 4 is above the maximum 3"} +Event {"event":"state","device":"sn:EXAMPLE0000001","rev":6,"state":{"available":true,"pending":{"led.brightness":2},"unread":[],"led":{"brightness":1}}} +Event {"event":"devices","devices":[]} +ServerMessage {"event":"devices","devices":[]} +ServerMessage {"id":4,"ok":{"rev":6}} diff --git a/crates/obsbot-proto/tests/schema.rs b/crates/obsbot-proto/tests/schema.rs new file mode 100644 index 0000000..4836487 --- /dev/null +++ b/crates/obsbot-proto/tests/schema.rs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Stale test of `schema/protocol-2.json`. `scripts/regenerate.sh` rewrites the file +//! (`OBSBOT_REGENERATE=1`); without it, any difference fails. + +use obsbot_proto::schema::{protocol_schema, schema_text}; +use std::path::PathBuf; + +fn schema_file() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../schema/protocol-2.json") +} + +#[test] +fn schema_file_is_current() { + let path = schema_file(); + let want = schema_text(); + if std::env::var_os("OBSBOT_REGENERATE").is_some() { + std::fs::write(&path, &want).expect("write schema/protocol-2.json"); + return; + } + let have = std::fs::read_to_string(&path).unwrap_or_default(); + assert!( + have == want, + "schema/protocol-2.json is stale: run scripts/regenerate.sh and commit the result" + ); +} + +#[test] +fn schema_names_every_envelope_and_the_description_format() { + let schema = protocol_schema(); + let defs = schema["$defs"].as_object().expect("$defs"); + for name in [ + "Request", + "Reply", + "Event", + "ErrorBody", + "ErrorCode", + "Device", + "Description", + "Setting", + "Action", + "State", + ] { + assert!(defs.contains_key(name), "the schema lacks {name}"); + } + let codes = serde_json::to_string(&defs["ErrorCode"]).unwrap(); + for code in obsbot_proto::ErrorCode::ALL { + assert!( + codes.contains(&format!("\"{}\"", code.as_str())), + "ErrorCode lacks {}", + code.as_str() + ); + } +} diff --git a/crates/obsbot-proto/tests/types.rs b/crates/obsbot-proto/tests/types.rs new file mode 100644 index 0000000..b731e84 --- /dev/null +++ b/crates/obsbot-proto/tests/types.rs @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Every protocol type round-trips byte for byte against `tests/golden/types.jsonl`, and the +//! additive-change rules of 4.1 rule 7 hold: unknown enum values, types and codes parse. + +use obsbot_proto::*; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::json; + +const GOLDEN: &str = include_str!("golden/types.jsonl"); + +fn round(json: &str) -> T { + let value: T = + serde_json::from_str(json).unwrap_or_else(|e| panic!("does not parse: {e}\n{json}")); + let back = serde_json::to_string(&value).unwrap(); + assert_eq!(back, json, "not byte-equal after a round trip"); + value +} + +#[test] +fn every_golden_line_round_trips_byte_equal() { + let mut seen = std::collections::BTreeSet::new(); + for line in GOLDEN + .lines() + .filter(|l| !l.is_empty() && !l.starts_with('#')) + { + let (name, json) = line.split_once(' ').expect("type name, space, JSON"); + seen.insert(name); + match name { + "RequestId" => drop(round::(json)), + "Support" => drop(round::(json)), + "Unit" => drop(round::(json)), + "UnitSpec" => drop(round::(json)), + "Bound" => drop(round::(json)), + "Asleep" => drop(round::(json)), + "Warning" => drop(round::(json)), + "SettingType" => drop(round::(json)), + "ErrorCode" => drop(round::(json)), + "Requirement" => drop(round::(json)), + "ExtensionUnit" => drop(round::(json)), + "Usb" => drop(round::(json)), + "Setting" => drop(round::(json)), + "Action" => drop(round::(json)), + "Description" => drop(round::(json)), + "Device" => drop(round::(json)), + "State" => drop(round::(json)), + "Patch" => drop(round::(json)), + "Hello" => drop(round::(json)), + "Request" => drop(round::(json)), + "Reply" => drop(round::(json)), + "ErrorBody" => drop(round::(json)), + "Event" => drop(round::(json)), + "ServerMessage" => drop(round::(json)), + other => panic!("unknown type name {other}"), + } + } + assert_eq!(seen.len(), 24, "every type has at least one golden line"); +} + +#[test] +fn additive_values_from_a_newer_daemon_still_parse() { + assert_eq!(round::(r#""beta""#), Support::Other("beta".into())); + assert_eq!( + round::(r#""matrix""#), + SettingType::Other("matrix".into()) + ); + assert_eq!(round::(r#""lux""#), Unit::Other("lux".into())); + assert_eq!( + round::(r#""rate_limited""#), + ErrorCode::Other("rate_limited".into()) + ); + let unknown: ServerMessage = serde_json::from_str(r#"{"event":"battery","level":3}"#).unwrap(); + assert_eq!( + unknown, + ServerMessage::Event(Event::Unknown), + "unknown event types are ignorable" + ); + let reply: Reply = + serde_json::from_str(r#"{"id":4,"ok":{"rev":6,"extra":true},"trace":"x"}"#).unwrap(); + assert_eq!( + reply.body, + ReplyBody::Ok(OkBody::Rev(RevOk { + rev: 6, + result: None + })), + "unknown fields are ignored" + ); +} + +#[test] +fn requests_parse_into_their_verbs() { + // Review Focus 4: a newer client's extra fields are ignored, not refused. + let extra = Request::from_line(r#"{"id":9,"device":"d","get":null,"trace":"abc"}"#).unwrap(); + assert_eq!( + extra.verb, + Verb::Get { + device: "d".into(), + paths: None + } + ); + let get = Request::from_line(r#"{"id":2,"device":"sn:EXAMPLE0000001","get":null}"#).unwrap(); + assert_eq!( + get.verb, + Verb::Get { + device: "sn:EXAMPLE0000001".into(), + paths: None + }, + "get:null is the full state" + ); + let stop = Request::from_line(r#"{"id":6,"device":"d","do":"gimbal.stop"}"#).unwrap(); + assert_eq!( + stop.verb, + Verb::Do { + device: "d".into(), + action: "gimbal.stop".into(), + params: Params::new() + }, + "with defaults to {{}}" + ); + let hello: Request = + serde_json::from_str(r#"{"id":1,"hello":{"protocol":2,"client":"x"}}"#).unwrap(); + assert_eq!( + hello.verb, + Verb::Hello(Hello { + protocol: 2, + client: "x".into(), + subscribe: true, + live: vec![] + }), + "subscribe defaults to true on the wire" + ); + assert_eq!( + serde_json::to_value(Hello::new("cli 1.0")).unwrap(), + json!({"protocol":2,"client":"cli 1.0","subscribe":false,"live":[]}) + ); +} + +#[test] +fn malformed_requests_are_refused_with_their_id_when_it_is_usable() { + let cases = [ + (r#"{"id":1}"#, Some(RequestId::Int(1)), "no verb"), + ( + r#"{"id":1,"device":"d","get":null,"set":{}}"#, + Some(RequestId::Int(1)), + "more than one verb", + ), + (r#"{"device":"d","get":null}"#, None, "no id"), + ( + r#"{"id":"x","get":null}"#, + Some(RequestId::Str("x".into())), + "names no device", + ), + ( + r#"{"id":2,"device":"d","set":[1]}"#, + Some(RequestId::Int(2)), + "malformed", + ), + (r#"{"id":1.5,"live":[]}"#, None, "malformed"), + ("not json", None, "not JSON"), + (r#"[1,2]"#, None, "malformed"), + ]; + for (line, id, part) in cases { + let err = Request::from_line(line).unwrap_err(); + assert_eq!(err.id, id, "{line}"); + assert!(err.message.contains(part), "{line}: {}", err.message); + } +} diff --git a/crates/obsbot-proto/tests/validate.rs b/crates/obsbot-proto/tests/validate.rs new file mode 100644 index 0000000..7f563ce --- /dev/null +++ b/crates/obsbot-proto/tests/validate.rs @@ -0,0 +1,489 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Table tests of the validator. Each row names the rule of the spec it pins. + +use obsbot_proto::validate::{ + Fit, check_description, check_value, validate_do, validate_get, validate_set, +}; +use obsbot_proto::{ + Action, Asleep, Description, ErrorCode, Patch, Setting, SettingType, State, Unit, Warning, +}; +use serde_json::{Value, json}; + +/// A small device: every setting type, a `requires`, an `asleep: ignored` leaf, a read-only +/// leaf, a destructive action and an action with an optional parameter. +fn desc() -> Description { + let w = |s: Setting| s.asleep(Asleep::Unknown).settle_ms(3000); + Description::default() + .setting("asleep", Setting::boolean("Asleep").readonly()) + .setting( + "led.brightness", + w(Setting::number("LED brightness", 0.0, 3.0).step(1.0)), + ) + .setting( + "zoom.factor", + Setting::number("Zoom", 1.0, 4.0) + .precision(0.01) + .asleep(Asleep::Ignored) + .settle_ms(3000), + ) + .setting( + "zoom.field_of_view", + w( + Setting::enumeration("Field of view", &["wide", "medium", "custom"]) + .writable_only(&["wide", "medium"]), + ), + ) + .setting( + "exposure.mode", + w(Setting::enumeration("Exposure mode", &["auto", "manual"])), + ) + .setting( + "exposure.time", + w(Setting::number("Exposure time", 100.0, 250_000.0).unit(Unit::Microseconds)) + .requires("exposure.mode", json!("manual")), + ) + .setting( + "zone.area.pan", + w(Setting::range("Zone pan", -150.0, 150.0).unit(Unit::Deg)), + ) + .setting( + "zone.return", + Setting::composite( + SettingType::Pose, + "Zone return", + &[("pan", -150.0), ("tilt", -70.0), ("zoom", 1.0)], + &[("pan", 150.0), ("tilt", 90.0), ("zoom", 4.0)], + ) + .readonly(), + ) + .setting("gimbal.invert", w(Setting::boolean("Invert"))) + .setting( + "presets", + Setting::list( + "Presets", + &[ + ("slot", Setting::number("Slot", 0.0, 2.0)), + ("name", Setting::string("Name", 32, true)), + ], + ), + ) + .action( + "preset.save", + Action::new("Save a preset", Asleep::Ignored) + .param("slot", Setting::number("Slot", 0.0, 2.0).step(1.0)) + .param("name", Setting::string("Name", 32, true).optional()), + ) + .action( + "factory_reset", + Action::new("Factory reset", Asleep::Unknown) + .param("confirm", Setting::boolean("Confirm")) + .warning(Warning::Destructive) + .warning(Warning::Restart), + ) + .limit("presets", 3) +} + +fn awake() -> State { + let mut s = State::new(true); + s.set("asleep", json!(false)); + s.set("exposure.mode", json!("auto")); + s +} + +fn patch(v: Value) -> Patch { + Patch(v.as_object().expect("a patch is an object").clone()) +} + +fn code_of_set(state: &State, p: Value) -> Option { + validate_set(&desc(), state, &patch(p)) + .err() + .map(|r| r.code) +} + +#[test] +fn the_test_description_is_consistent() { + assert_eq!(check_description(&desc()), Vec::::new()); +} + +#[test] +fn set_table() { + use ErrorCode::*; + let rows: Vec<(&str, Value, Option)> = vec![ + ("valid, dotted", json!({"led.brightness": 2}), None), + ("valid, nested", json!({"led": {"brightness": 2}}), None), + ( + "valid, float that is a whole number", + json!({"led.brightness": 2.0}), + None, + ), + ( + "valid range", + json!({"zone.area.pan": {"min": -20, "max": 20}}), + None, + ), + ( + "valid range, nested and dotted mixed", + json!({"zone": {"area.pan": {"min": -20, "max": 20}}}), + None, + ), + ( + "wrong type: string for a number", + json!({"led.brightness": "2"}), + Some(Invalid), + ), + ( + "wrong type: number for a bool", + json!({"gimbal.invert": 1}), + Some(Invalid), + ), + ( + "wrong type: null", + json!({"gimbal.invert": null}), + Some(Invalid), + ), + ( + "wrong type: range without max", + json!({"zone.area.pan": {"min": -20}}), + Some(Invalid), + ), + ( + "wrong type: range with an extra field", + json!({"zone.area.pan": {"min": -20, "max": 20, "mid": 0}}), + Some(Invalid), + ), + ( + "read-only leaf (4.2)", + json!({"zone.return": {"pan": 0, "tilt": 0, "zoom": 1}}), + Some(Invalid), + ), + ("read-only list", json!({"presets": []}), Some(Invalid)), + ( + "a group is not a leaf (4.2 Leaves)", + json!({"zoom": 2}), + Some(Invalid), + ), + ( + "a leaf named twice", + json!({"zoom": {"factor": 2}, "zoom.factor": 3}), + Some(Invalid), + ), + ("empty patch", json!({}), Some(Invalid)), + ( + "unknown path, a typo", + json!({"led.brigthness": 2}), + Some(Unsupported), + ), + ( + "a path of another model, not on this device", + json!({"tracking.mode": "static"}), + Some(Unsupported), + ), + ("above max", json!({"led.brightness": 4}), Some(OutOfRange)), + ("below min", json!({"zoom.factor": 0.5}), Some(OutOfRange)), + ( + "not on the step", + json!({"led.brightness": 1.5}), + Some(OutOfRange), + ), + ( + "enum value shown but not writable", + json!({"zoom.field_of_view": "custom"}), + Some(OutOfRange), + ), + ( + "enum value unknown", + json!({"zoom.field_of_view": "narrow"}), + Some(OutOfRange), + ), + ( + "range component out of bounds", + json!({"zone.area.pan": {"min": -160, "max": 20}}), + Some(OutOfRange), + ), + ( + "range min above max", + json!({"zone.area.pan": {"min": 30, "max": 20}}), + Some(OutOfRange), + ), + ( + "requires not met: time alone while auto (4.2)", + json!({"exposure.time": 20000}), + Some(Conflict), + ), + ( + "requires met by the same patch (4.2)", + json!({"exposure": {"mode": "manual", "time": 20000}}), + None, + ), + ( + "invalid wins over unsupported", + json!({"led.brightness": "x", "nope": 1}), + Some(Invalid), + ), + ( + "unsupported wins over out_of_range", + json!({"led.brightness": 9, "nope": 1}), + Some(Unsupported), + ), + ( + "out_of_range wins over conflict", + json!({"led.brightness": 9, "exposure.time": 20000}), + Some(OutOfRange), + ), + // Review Focus 1: float noise from a client's arithmetic is not a step violation. + ( + "float noise on a stepped value", + json!({"led.brightness": 2.000_000_000_1}), + None, + ), + // Review Focus 2: the CLI's `min,max` syntax sent as a JSON array. + ( + "a range as an array", + json!({"zone.area.pan": [-20, 20]}), + Some(Invalid), + ), + // Review Focus 3: malformed paths are refused, never a panic. + ( + "an empty path segment", + json!({"led..brightness": 2}), + Some(Unsupported), + ), + ("a trailing dot", json!({"led.": 2}), Some(Unsupported)), + ("an empty key", json!({"": 2}), Some(Unsupported)), + ]; + for (name, p, want) in rows { + assert_eq!(code_of_set(&awake(), p), want, "row: {name}"); + } +} + +#[test] +fn asleep_ignored_is_refused_only_while_asleep() { + let mut asleep = awake(); + asleep.set("asleep", json!(true)); + assert_eq!( + code_of_set(&asleep, json!({"zoom.factor": 2})), + Some(ErrorCode::Asleep), + "ignored while asleep" + ); + assert_eq!( + code_of_set(&awake(), json!({"zoom.factor": 2})), + None, + "awake: written" + ); + assert_eq!( + code_of_set(&asleep, json!({"led.brightness": 2})), + None, + "asleep: unknown is written (5.7)" + ); + assert_eq!( + code_of_set(&asleep, json!({"zoom.factor": 2, "exposure.time": 300})), + Some(ErrorCode::Conflict), + "conflict wins over asleep" + ); +} + +#[test] +fn a_refusal_names_the_path_at_fault() { + let r = validate_set( + &desc(), + &awake(), + &patch(json!({"led.brightness": 2, "zoom.factor": 9})), + ) + .unwrap_err(); + assert_eq!(r.code, ErrorCode::OutOfRange); + assert_eq!(r.path.as_deref(), Some("zoom.factor")); + assert!(r.message.contains("zoom.factor"), "{}", r.message); +} + +#[test] +fn a_valid_patch_comes_back_as_leaves_in_path_order() { + let leaves = validate_set( + &desc(), + &awake(), + &patch(json!({"zoom": {"factor": 2}, "led.brightness": 1})), + ) + .unwrap(); + assert_eq!( + leaves, + vec![ + ("led.brightness".to_string(), json!(1)), + ("zoom.factor".to_string(), json!(2)) + ] + ); +} + +#[test] +fn do_table() { + use ErrorCode::*; + let rows: Vec<(&str, &str, Value, Option)> = vec![ + ("valid", "preset.save", json!({"slot": 1}), None), + ( + "valid with the optional name", + "preset.save", + json!({"slot": 1, "name": "Desk"}), + None, + ), + ( + "unknown action", + "preset.load", + json!({"slot": 1}), + Some(Unsupported), + ), + ("missing parameter", "preset.save", json!({}), Some(Invalid)), + ( + "unknown parameter", + "preset.save", + json!({"slot": 1, "speed": 3}), + Some(Invalid), + ), + ( + "wrong type", + "preset.save", + json!({"slot": "one"}), + Some(Invalid), + ), + ( + "out of range", + "preset.save", + json!({"slot": 3}), + Some(OutOfRange), + ), + ( + "name too long", + "preset.save", + json!({"slot": 0, "name": "x".repeat(33)}), + Some(OutOfRange), + ), + ( + "name not ASCII", + "preset.save", + json!({"slot": 0, "name": "Küche"}), + Some(OutOfRange), + ), + ( + "destructive without confirm (4.2 warning)", + "factory_reset", + json!({}), + Some(Invalid), + ), + ( + "destructive with confirm false", + "factory_reset", + json!({"confirm": false}), + Some(Invalid), + ), + ( + "destructive with confirm true", + "factory_reset", + json!({"confirm": true}), + None, + ), + ]; + for (name, action, params, want) in rows { + let params = params.as_object().unwrap().clone(); + let got = validate_do(&desc(), &awake(), action, ¶ms) + .err() + .map(|r| r.code); + assert_eq!(got, want, "row: {name}"); + } + let mut asleep = awake(); + asleep.set("asleep", json!(true)); + let params = json!({"slot": 1}).as_object().unwrap().clone(); + assert_eq!( + validate_do(&desc(), &asleep, "preset.save", ¶ms) + .err() + .map(|r| r.code), + Some(ErrorCode::Asleep) + ); +} + +#[test] +fn get_accepts_leaves_groups_and_daemon_fields_only() { + let ok = |paths: &[&str]| { + validate_get( + &desc(), + &paths.iter().map(|p| p.to_string()).collect::>(), + ) + }; + assert!( + ok(&[ + "zoom.factor", + "zone", + "zone.area", + "pending", + "available", + "unread" + ]) + .is_ok() + ); + assert_eq!(ok(&["tracking"]).unwrap_err().code, ErrorCode::Unsupported); + assert_eq!( + ok(&["zoom.factorx"]).unwrap_err().code, + ErrorCode::Unsupported + ); + // Review Focus 5: a component of a leaf is not a path of its own. + assert_eq!( + ok(&["zone.area.pan.min"]).unwrap_err().code, + ErrorCode::Unsupported + ); +} + +#[test] +fn state_values_may_be_null_where_nullable_and_raw_where_undecodable() { + let mode = Setting::enumeration("Mode", &["a", "b"]); + assert!( + check_value(&mode, &json!("raw:7"), Fit::State).is_ok(), + "4.2 Undecodable values" + ); + assert!( + check_value(&mode, &json!("raw:7"), Fit::Write).is_err(), + "raw values are never written" + ); + assert!( + check_value(&mode, &Value::Null, Fit::State).is_err(), + "null only where nullable" + ); + assert!(check_value(&mode.clone().nullable(), &Value::Null, Fit::State).is_ok()); +} + +#[test] +fn check_description_finds_broken_descriptions() { + let broken = Description::default() + .setting("Zoom.Factor", Setting::number("Zoom", 4.0, 1.0)) + .setting( + "zone.area", + Setting::boolean("Zone") + .asleep(Asleep::Unknown) + .settle_ms(3000), + ) + .setting("zone.area.pan", Setting::range("Pan", -150.0, 150.0)) + .setting( + "mode", + Setting::enumeration("Mode", &["a"]) + .writable_only(&["b"]) + .asleep(Asleep::Unknown) + .settle_ms(3000), + ) + .action( + "factory_reset", + Action::new("Reset", Asleep::Unknown).warning(Warning::Destructive), + ) + .limit("zone.area", 3); + let problems = check_description(&broken).join("\n"); + for expected in [ + "Zoom.Factor: not lower-case", + "Zoom.Factor: min is above max", + "Zoom.Factor: a writable leaf needs asleep", + "zone.area: a leaf that is also a group", + "zone.area.pan: a writable leaf needs asleep", + "mode: writable holds a value that values does not", + "factory_reset: a destructive action needs the bool parameter confirm", + "limits.zone.area: not a list setting", + ] { + assert!( + problems.contains(expected), + "missing {expected:?} in:\n{problems}" + ); + } +} diff --git a/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" +}