diff --git a/docs/field-ledger.md b/docs/field-ledger.md index 5f4ed5a..870ac4f 100644 --- a/docs/field-ledger.md +++ b/docs/field-ledger.md @@ -135,12 +135,12 @@ follows 1–3 s after the write, #81; the target-lock status about 1 s). Per-lea | Action | Frames, in order | Effect and readback | Evidence | §9.3 | Asleep | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------- | ---------------------------------- | -| `gimbal.move` | short `16 02 00 00`; framed `0x6444`: f32 roll, pitch, yaw, −1000 keeps an axis. With `speed`: framed `0x6544`: 3 × f32 speed, then the three targets | live pose | capture 18 (`0x6444`); measured 2026-10-25; measured 2026-10-31 (#98: speed 1, 5, 100 °/s; tilt 80, pan 145) | U-9 (limits) | unknown | +| `gimbal.move` | framed `0x6444`: f32 roll, pitch, yaw, −1000 keeps an axis. With `speed`: framed `0x6544`: 3 × f32 speed, then the three targets | live pose; while the tracking is on: CAM-34 | capture 18 (`0x6444`); measured 2026-10-25; measured 2026-10-31 (#98: speed 1, 5, 100 °/s; tilt 80, pan 145) | U-9 (limits) | unknown | | `gimbal.stop` | framed `0x6704`, no data | the live pose stops changing | measured 2026-10-31 (#98) | | unknown | -| `gimbal.turn` | short `16 02 00 00`; framed `0x6484`: f32 roll 0, pitch, yaw in °/s; the yaw sign flips when exactly one of `image.mirror` and `gimbal.invert` is on | turns until `gimbal.stop` or speed 0 | capture 10; measured 2026-10-31 (#98, the sign flip included) | | unknown (CAM-7) | +| `gimbal.turn` | framed `0x6484`: f32 roll 0, pitch, yaw in °/s; the yaw sign flips when exactly one of `image.mirror` and `gimbal.invert` is on | turns until `gimbal.stop` or speed 0; while the tracking is on: CAM-34 | capture 10; measured 2026-10-31 (#98, the sign flip included) | | unknown (CAM-7) | | `zoom.move` | framed `0x6884`: i32 speed (slow 10, medium 30, fast 50), f32 factor | query `0x6804` | capture 11 (30); measured 2026-10-31 (#98) | U-12 (10, 50) | unknown | | `zoom.stop` | framed `0x68c4`, no data | the factor stops changing | measured 2026-10-31 (#98) | | unknown | -| `preset.save` | live pose unless `pose` is given; framed `0x3944` (empty slot) or `0x3e04` (used slot), preset layout; then framed `0x3a84` 100 ms later when a name is given or the slot was empty | `presets` | capture 10 | | ignored (CAM-22) | +| `preset.save` | live pose unless `pose` is given; framed `0x3944` (empty slot) or `0x3e04` (used slot), preset layout; then framed `0x3a84` 100 ms later only when a name is given; none is made up | `presets` | capture 10 | | ignored (CAM-22) | | `preset.recall` | framed `0x39c4`: u32 slot, four f32 1.0 (the first is a speed scalar) | live pose, `zoom.factor` | capture 10 | | unknown | | `preset.rename` | framed `0x3a84`: u32 slot, ASCII name without terminator | `presets` | capture 10 | | unknown | | `preset.delete` | framed `0x3984`: u32 slot; framed `0x3d84` 100 ms later: the cleared settings block | `presets`; the preset the second frame addresses is CAM-18 | capture 10 | | unknown | diff --git a/docs/superpowers/plans/2026-11-13-obsbot4linux-w0-contracts.md b/docs/superpowers/plans/2026-11-13-obsbot4linux-w0-contracts.md index aed05cc..e90ee6a 100644 --- a/docs/superpowers/plans/2026-11-13-obsbot4linux-w0-contracts.md +++ b/docs/superpowers/plans/2026-11-13-obsbot4linux-w0-contracts.md @@ -8,7 +8,7 @@ **Tech Stack:** Rust 2024 edition, MSRV 1.88 (CI toolchain 1.99.1), serde 1, serde_json 1 (default features: maps sorted by key), schemars 1.2 (JSON Schema draft 2020-12), jq in CI. -**Spec:** `docs/superpowers/specs/2026-11-01-obsbot4linux-1.0-design.md` (revision 2, approved 2026-11-13), sections 3, 4, 5.1 to 5.3, 5.12, 6, 8, 9.3, 10.3 (W0) and 11; field ledger `docs/field-ledger.md`. +**Spec:** `docs/superpowers/specs/2026-11-01-obsbot4linux-1.0-design.md` (revision 2, approved 2026-11-13, with the change after approval of its section 15.1), sections 2, 3, 4, 5.1 to 5.3, 5.12, 6, 8, 9.3, 10.3 (W0) and 11; field ledger `docs/field-ledger.md`. ## Global Constraints @@ -28,6 +28,7 @@ - "Public enums are `#[non_exhaustive]`, so additive protocol changes do not break Rust callers" (spec 6.1). - Fakes: "`fake-a` uses the Tiny 2 Lite's description, for client work; `support: tested`"; "`fake-b` is deliberately different: other enum values, whole groups missing (no `gimbal`, no `views`), a setting and an action under a group no client knows, another preset count (5), `readback:false` leaves, leaves with `asleep: ignored` and `asleep: unknown`, a `requires`, a readback that lags its write by a set time, `support: untested`"; "`unknown` is a device with an unregistered product id of a known vendor id"; "synthetic serials (`sn:FAKEA0000001`)"; test control on `.test` with `plug`, `unplug`, `sleep`, `wake`, `fail_next`, "not part of protocol 2 and not in any schema or description" (spec 5.12). - "No raw send path exists anywhere: not in the daemon, the CLI, the protocol or the fakes" (spec 8.3). +- "A request sends exactly what it names: the frames of that setting or action, nothing before them and nothing after them" (spec 2, changed after approval: spec 15.1 row 88). `preset.save` without `name` sends no name: the daemon invents none (spec 4.4, 15.1 row 90). `gimbal.move` and `gimbal.turn` "leave `tracking.mode` alone" (spec 4.4); "`touched` names the groups whose values the camera itself changes in response to the leaf or action" (spec 8.2). - Tiny 2 Lite: "`settle_ms`: 3000 for every writable leaf with a readback"; "`readback:false`: `hand.move_gimbal`, `led.blink`"; "`presets`: at most 3 (`limits.presets`)"; "the description written in stage W0 contains the field, value or bound" of every row of 9.3 (spec 8.8, 4.3, 9.3). - "Golden files cover protocol 2 and the descriptions. An intentional change updates them in the same PR" (spec 11). - Public text (spec 8.5): English; paths, command ids, ranges, units, the model's own USB ids, evidence levels and opaque keys only; no private paths, no vendor-app or firmware names, no serial numbers (the synthetic `EXAMPLE0000001`, `FAKEA…`, `FAKEB…` are allowed). @@ -65,7 +66,7 @@ The spec leaves these details of the contract open; this plan fixes them. Each c 2. **Canonical JSON.** Maps are sorted by key (serde_json without `preserve_order`); struct fields keep declaration order; an integral number is written without a fraction (`3`, not `3.0`); `readonly`, `nullable`, `readback` and `no_effect` are always written (a JavaScript client reads a missing `readback` as false); other optional Setting fields are left out when empty; every writable enum carries `writable`. Golden files are in this form. 3. **Forward compatibility.** `Support`, `SettingType`, `Unit`, `Asleep`, `Warning` and `ErrorCode` have a catch-all `Other(String)`; `Event` has `Unknown`. A newer daemon's additive values parse (spec 4.1 rule 7). 4. **`trait Camera` signature details** (spec 8.2 shows the shape): `identity`, `read`, `apply` and `act` take a `&Ctx` that the model checks before every frame (the 15 s deadline of 4.1 rule 9; a stop cutting a group read, 5.6); `Ctx::new` takes both at once, because a request's touched re-read has the request's deadline and is also a group read a stop cuts. `identity` takes a `Need` (5.5: send only the missing queries). `act` takes `&Params` (an object) and the published `&State` that `check` saw (a used or empty preset slot, the mirror and invert flags), and returns `Option`. Spec 8.2's `stops` and `continuing` become one method `motion(action) -> Motion` with `Motion::{None, Move, Continuing { stop }, Stop}`, because the daemon must also know which actions are motion actions: 5.6 "any other motion action of that device clears the record" (4.1 rule 6 lists them). A `Stop` clears the record only when it is the stop the record names. One method is added: `check(action, params, state) -> Result<(), Refusal>`, the model's checks that the description cannot express, run before any frame (an empty slot, an unknown view id, a list in `unread`: `conflict`; `gimbal.move` without an axis: `invalid`). The daemon calls `check` only after `validate_do` passed, so the model's codes rank after every code of the validator (decision 9). A group whose read depends on a condition (the Tiny 2 Lite's target, 8.8) is decided by the model from its own last reads (`read` docs). -5. **`touched` includes the written leaf's own group.** The conformance check requires it, so the state the reply publishes can show the write (spec 9.4 counts lags "at the touched re-read"). +5. **`touched` includes the written leaf's own group.** The conformance check requires it, so the state the reply publishes can show the write (spec 9.4 counts lags "at the touched re-read"). Beyond that, `touched` names only groups the camera itself changes in response (spec 8.2): no model sends a frame the request did not name (spec 2), so `fake-a`'s `gimbal.move` and `gimbal.turn` touch nothing and leave `tracking.mode` alone. What the fake does on a move while its tracking is on (it moves and keeps its mode) is the fake's own behaviour, not a statement about the camera, which is CAM-34. 6. **Enumeration seam.** `trait Bus { scan, open }`, `Found`, `ModelEntry` (data only: key, display name, USB pairs, support, identity source `IdentitySource::{UsbSerial, Query, Absent}`, whether a firmware query exists, identity tier, extension-unit GUID, selectors; the two identity fields let the daemon end an identity series and send no attempt for a model with neither query, 5.5), `Registry` with `lookup` and `shared_pairs`, `Lookup`. The fake bus and track A's sysfs bus implement `Bus`, so the daemon has one enumeration path and `unknown` "enters through the same registry lookup as a real device" (spec 5.12). The constructor of a model stays with its bus. 7. **`trait Transport`** (spec 3.1): `xu_len`, `xu_get`, `xu_set`, `ctrl_get`, `ctrl_set`, `ctrl_range`, `usb` (fresh sysfs facts), `usb_serial`, `on_air`. Track A adds anything else by a contract-change PR. 8. **Daemon entry point.** `obsbot4linuxd::Config { socket, fake: Option>, stop }` and `run(Config) -> std::io::Result<()>`. In-process tests build the `FakeBus` themselves and may use its Rust-only `set_delay`. The test-control socket answers each line with `{"ok":}` or `{"error":""}` (`FakeBus::handle_line`). @@ -73,7 +74,7 @@ The spec leaves these details of the contract open; this plan fixes them. Each c 10. **`on_air.apps`** is a `list` of `{name}` items, because the description format's lists describe object items (spec 4.2 `item{ → Setting}`). This changes the state shape of spec 4.3 (`apps[]` of process names) to `[{"name": …}]`, which clients see; it goes into the spec's "Changes" table with W0. 11. **CLI-only error codes** (`usage`, `ambiguous_device`, `no_device`, `daemon`) are not in `ErrorCode`; track C defines them in the CLI, as spec 6.2 says they "exist only in the CLI". 12. **Tiny 2 Lite numbers the spec does not state.** Backed by the field ledger: gain 1–64, exposure compensation 0–18 default 9, focus position 0–100. Not backed by a ledger row, a capture or a measurement (none of the spec, the ledger, the carried code or the protocol notes holds them; they are working values of this plan): brightness, contrast, saturation, hue, sharpness 0–100 default 50; white balance 2000–10000 K step 100 default 5000, automatic by default; gain default 1; exposure time 100–250000 µs (1–2500 in 100 µs), default 33000 µs; focus position default 0. `tiny2lite::description::UNCONFIRMED` lists each of these by key (`image.brightness@min`, `image.white_balance.temperature#step`, …), and a unit test checks every key resolves. They replace 0.13's run-time `ctrl_range` check in the manual-exposure flow, so a wrong bound refuses valid values or lets the camera reject a write: track A confirms each in the ledger (from `Transport::ctrl_range` on the camera, or a capture) or corrects it, and empties `UNCONFIRMED`, before the evidence-level test of stage W3 (8.5). From 0.13.2: the hand area takes the gimbal range (pan −150..150, tilt −70..90), `hand.return_after` ≥ 0 s with no maximum, `gimbal.move` pan −150..150, tilt −70..90, speed 1–180 °/s. -13. **Fakes.** Vendor id `0x3564` (in the vendor table, so `unknown` is unsupported, not ignored), products `fa0a`, `fa0b`, `fa00`; USB paths `9-`; a restart (a `warning: restart` write or action) makes the device vanish for 1 s and come back with a new device number; `fake-b`'s `led.brightness` readback lags 250 ms and its `exposure.time` is stored rounded down to 100, so both ways out of `pending` happen; `fake-b`'s settle bound is 1000 ms. `fake-a`'s poll plan has one group per first path segment, not the Tiny 2 Lite's real grouping. +13. **Fakes.** Vendor id `0x3564` (in the vendor table, so `unknown` is unsupported, not ignored), products `fa0a`, `fa0b`, `fa00`; USB paths `9-`; a restart (a `warning: restart` write or action) makes the device vanish for 1 s and come back with a new device number; `fake-b`'s `led.brightness` readback lags 250 ms and its `exposure.time` is stored rounded down to 100, so both ways out of `pending` happen; `fake-b`'s settle bound is 1000 ms. `fake-a`'s poll plan has one group per first path segment, not the Tiny 2 Lite's real grouping. A preset saved without `name` is stored with an empty name in both fakes: their own behaviour, since the daemon names no preset (spec 4.4, 15.1 row 90) and what the camera reports for an unnamed preset is CAM-35. 14. **The client** (spec 6.1) is written by track C, as spec 10.3 says; W0 reserves `crates/obsbot-proto/src/client.rs` so track C never edits `lib.rs`. `obsbot-proto` holds only the constants a client needs (`PROTOCOL`, the socket names, `MAX_REQUEST_LINE`, `REQUEST_DEADLINE`, `CLIENT_REPLY_TIMEOUT`); the daemon's limits (64 connections, 32 queued requests, 64 outbound lines) are constants of `obsbot4linuxd`, written by track B. 15. **The 0.13 modules stage G keeps in `obsbot-device`.** G moves the 0.13 `state.rs` to `crates/obsbot-device/src/legacy/state.rs` and splits `legacy/full_status.rs` and `legacy/current_pose.rs` off the 0.13 camera module. Task 6 deletes `legacy/full_status.rs`, whose only users are the 0.13 daemon and CLI. `legacy/state.rs` and `legacy/current_pose.rs` stay: the carried Tiny 2 Lite flows still use them, and track A removes them with the flows it rewrites (spec 3.2). So at the end of W0 the 0.13 daemon, client, protocol, CLI and full status are gone, and the 0.13 state module is not. @@ -131,7 +132,7 @@ Tasks 7 and 8 each depend on two wave-2 tasks (2 and 5): such a task starts from `Cargo.lock` changes in Tasks 1, 5, 6 and 11. When a rebase conflicts in it, take `rebuild`'s version and run `cargo build --workspace`; commit the result. -The code of this plan was built and run in a copy of the workspace on 2026-11-13 (rustc 1.99.1), again after the review's fix round: `cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D warnings` and every test below passed. The code in the steps is that code. +The code of this plan was built and run in a copy of the workspace on 2026-11-13 (rustc 1.99.1), again after the review's fix round, and again after the changes of spec 15.1 rows 88 and 90 (with stand-ins for stage G's files; the regenerated description files and schema did not change; 185 tests in the workspace, one more than before row 90: `a_preset_saved_without_a_name_gets_none`, which fails when the fake's default name is put back): `cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D warnings` and every test below passed. The code in the steps is that code. --- @@ -4182,7 +4183,9 @@ pub trait Camera: Send { ) -> Result>; /// The groups to re-read after a leaf was written or an action ran. For a leaf with a - /// readback this includes the leaf's own group. + /// readback this includes the leaf's own group. Beyond that, only the groups whose values + /// the camera itself changes in response: a model sends exactly the frames of the request + /// (design spec 2), so no group is touched by a frame of the model's own. fn touched(&self, target: Target<'_>) -> Vec; /// What `action` is to the queue and to the continuing-motion record (4.1 rules 2 and 6, @@ -6219,6 +6222,10 @@ refusal first. There is no rollback. 6. Motion actions reply when the camera accepted the command; the pose moves through later events. + An action sends exactly what it names, nothing before and nothing after it: the daemon + sends no `tracking.mode` write with a `gimbal.move`; what the camera then does with its + tracking shows in the state. A client that wants the tracking off first sets + `tracking.mode` to `static` in its own request. 7. Additive changes keep protocol 2: fields, actions, models, enum values, live topics, setting types, units. Clients ignore unknown fields and event types, show unknown enum values as raw text, show settings of an unknown type read-only as text, and treat an unknown error @@ -6289,7 +6296,8 @@ rebuilds from the reply. Revs of two connections are never compared. whose last read failed (they keep their last value). - A camera value the model cannot decode is the string `raw:`. - List items carry the camera's own numbers: preset slots start at 0; view ids are the - camera's ids. + camera's ids. A preset's `name` is what the camera reports, empty included: the daemon + names no preset (`preset.save` without `name` sends no name). ## 9. Path dictionary @@ -6404,7 +6412,7 @@ git commit -m "docs: protocol 2, normative part and path dictionary" - Consumes (Task 5): `Camera`, `CameraError`, `Ctx`, `Group`, `Identity`, `IdentitySource`, `LiveTier`, `Motion`, `Need`, `SendTable`, `Target`, `Tier`, `Bus`, `Found`, `Lookup`, `ModelEntry`, `Registry`, `UsbId`; (Task 7) `conformance::check` in the tests; (Task 8) `tiny2lite::description::description`; (Task 1, 2) the proto types, `values::{number, same}`, `validate::Refusal`, `validate::validate_set` in the tests. - Produces (module `obsbot_device::models::fake`): - `const VENDOR: u16 = 0x3564`, `const UNKNOWN_PRODUCT: u16 = 0xfa00`, `const KEYS: [&str; 3] = ["fake-a", "fake-b", "unknown"]`, `const RESTART: Duration` (1 s), `static REGISTRY: Registry`, `fn description(key: &str) -> Option` - - `mod a`: `const PRODUCT: u16 = 0xfa0a`, `const ENTRY: ModelEntry` (`support: Tested`, `identity: Query`, `firmware_query: true`), `fn description() -> Description`; its `motion` marks are those of the Tiny 2 Lite (4.1 rules 2 and 6): `gimbal.turn` continuing with the stop `gimbal.stop`, `gimbal.stop` and `zoom.stop` stops, `gimbal.move`, `zoom.move`, `preset.recall`, `view.go`, `zone.go_return`, `hand.go_return`, `power_on.go` moves + - `mod a`: `const PRODUCT: u16 = 0xfa0a`, `const ENTRY: ModelEntry` (`support: Tested`, `identity: Query`, `firmware_query: true`), `fn description() -> Description`; its `motion` marks are those of the Tiny 2 Lite (4.1 rules 2 and 6): `gimbal.turn` continuing with the stop `gimbal.stop`, `gimbal.stop` and `zoom.stop` stops, `gimbal.move`, `zoom.move`, `preset.recall`, `view.go`, `zone.go_return`, `hand.go_return`, `power_on.go` moves; `gimbal.move` and `gimbal.turn` touch no group and leave `tracking.mode` alone (spec 2, 4.4) - `mod b`: `const PRODUCT: u16 = 0xfa0b`, `const ENTRY: ModelEntry` (`support: Untested`, `identity: Query`, `firmware_query: true`; no stop and no continuing action, `preset.recall` a move), `const LAG: Duration` (250 ms), `const SETTLE_MS: u32 = 1000`, `const TIME_STEP: f64 = 100.0`, `fn description() -> Description` - `struct FakeBus` with `new(&[&str]) -> Result`, `plug(&str) -> Result` (returns the device id), `unplug(&str)`, `sleep(&str)`, `wake(&str)`, `fail_next(&str)` (each `-> Result<(), String>`, ids `sn:` or `port:`), `set_delay(&str, Duration)`, `ops(&str) -> Vec`, `handle_line(&str) -> String` (the test-control socket of spec 5.12), and `impl Bus for FakeBus` - `enum Op { Open, Identity(Need), Read(String), Apply(String, Value), Act(String, Params) }` @@ -6892,15 +6900,89 @@ fn actions_change_the_state_and_view_save_returns_its_id() { .unwrap(); let state = state_of(&mut *cam); assert_eq!(state.at("gimbal.pan"), Some(&json!(20))); - assert_eq!( - state.at("tracking.mode"), - Some(&json!("static")), - "4.4: a move switches tracking to static" - ); - assert_eq!( - cam.touched(Target::Action("gimbal.move")), - ["tracking", "framing"] - ); +} + +/// Spec 2: an action sends exactly what was asked, nothing before it and nothing after it. +/// 0.13 switched the tracking to static before a manual move; 1.0 does not, so a move while +/// the tracking is on is one operation and leaves `tracking.mode` alone. What the camera +/// itself does then is CAM-34; the fake only moves. +#[test] +fn a_move_while_tracking_sends_only_the_move() { + let bus = bus(); + let mut cam = open(&bus, "fa0a"); + cam.apply("tracking.mode", &json!("normal"), &Ctx::none()) + .unwrap(); + for (action, with) in [ + ("gimbal.move", json!({"pan": 20})), + ("gimbal.turn", json!({"pan_speed": 10})), + ] { + let before = bus.ops("sn:FAKEA0000001").len(); + cam.act( + action, + ¶ms(with.clone()), + &State::new(true), + &Ctx::none(), + ) + .unwrap(); + assert_eq!( + bus.ops("sn:FAKEA0000001")[before..], + [Op::Act(action.into(), params(with))], + "an action sends exactly what was asked (spec 2): {action} is one command, no tracking switch before or after it" + ); + assert_eq!( + read_leaf(&mut *cam, "tracking", "tracking.mode"), + json!("normal"), + "an action sends exactly what was asked (spec 2): {action} leaves tracking.mode alone" + ); + assert_eq!( + cam.touched(Target::Action(action)), + Vec::::new(), + "{action} touches no group: only the camera could change the tracking, and that is CAM-34" + ); + } +} + +/// Spec 2 and 4.4: `preset.save` without `name` sends no rename and the daemon invents no +/// name (0.13 named the preset `Preset `). The fakes store an empty name for it, +/// their own behaviour; what the camera reports for an unnamed preset is CAM-35. +#[test] +fn a_preset_saved_without_a_name_gets_none() { + let bus = bus(); + for (product, id) in [("fa0a", "sn:FAKEA0000001"), ("fa0b", "sn:FAKEB0000001")] { + let mut cam = open(&bus, product); + let before = bus.ops(id).len(); + cam.act( + "preset.save", + ¶ms(json!({"slot": 0})), + &State::new(true), + &Ctx::none(), + ) + .unwrap(); + assert_eq!( + bus.ops(id)[before..], + [Op::Act("preset.save".into(), params(json!({"slot": 0})))], + "{product}: an action sends exactly what was asked (spec 2): one act, no rename after it" + ); + let state = state_of(&mut *cam); + assert_eq!( + state.at("presets").unwrap()[0]["name"], + json!(""), + "{product}: the daemon invents no name (spec 2, 4.4)" + ); + cam.act( + "preset.save", + ¶ms(json!({"slot": 1, "name": "Desk"})), + &State::new(true), + &Ctx::none(), + ) + .unwrap(); + let state = state_of(&mut *cam); + assert_eq!( + state.at("presets").unwrap()[1]["name"], + json!("Desk"), + "{product}: a name that was given is stored" + ); + } } /// 4.1 rules 2 and 6, 5.6: the daemon learns from `motion` which actions jump the queue and @@ -7607,7 +7689,6 @@ fn touched(target: Target<'_>) -> Vec<&'static str> { Target::Leaf(l) if l.starts_with("gestures.") => vec!["zoom"], Target::Leaf(_) => vec![], Target::Action(a) => match a { - "gimbal.move" | "gimbal.turn" => vec!["tracking", "framing"], "preset.recall" | "view.go" => vec!["zoom", "gimbal"], "preset.save" | "preset.rename" | "preset.delete" => vec!["presets"], "power_on.use_preset" => vec!["presets", "power_on"], @@ -7658,26 +7739,25 @@ fn list(values: &mut Values, path: &str) -> Vec { fn act(values: &mut Values, action: &str, p: &Params) -> Option { let pose = |values: &Values| p.get("pose").cloned().unwrap_or_else(|| live_pose(values)); match action { + // A move sends only the move (spec 2): the fake sets the pose and leaves + // `tracking.mode` as it is, whatever the mode. That is the fake's own behaviour, not + // the camera's; what the camera does with a move while it tracks is CAM-34. + // `gimbal.turn` changes nothing the fake stores (it has no turning pose). "gimbal.move" => { for (param, leaf) in [("pan", "gimbal.pan"), ("tilt", "gimbal.tilt")] { if let Some(v) = p.get(param) { values.insert(leaf.into(), v.clone()); } } - values.insert("tracking.mode".into(), json!("static")); - } - "gimbal.turn" => { - values.insert("tracking.mode".into(), json!("static")); } "zoom.move" => { values.insert("zoom.factor".into(), p["factor"].clone()); } "preset.save" => { let slot = p["slot"].as_u64().unwrap_or(0); - let name = p - .get("name") - .cloned() - .unwrap_or_else(|| json!(format!("Preset {}", slot + 1))); + // The fake's own behaviour: an unnamed preset has an empty name. What the + // camera reports for one is CAM-35; the daemon never invents a name (spec 2). + let name = p.get("name").cloned().unwrap_or_else(|| json!("")); let item = json!({"slot": slot, "name": name, "pose": pose(values)}); let mut items: Vec = list(values, "presets") .into_iter() @@ -7992,10 +8072,8 @@ fn act(values: &mut Values, action: &str, p: &Params) -> Option { match action { "preset.save" => { let slot = p["slot"].clone(); - let name = p - .get("name") - .cloned() - .unwrap_or_else(|| json!(format!("Preset {}", slot.as_u64().unwrap_or(0) + 1))); + // The fake's own behaviour: an unnamed preset has an empty name (CAM-35). + let name = p.get("name").cloned().unwrap_or_else(|| json!("")); items.retain(|i| i["slot"] != slot); items.push(json!({"slot": slot, "name": name})); items.sort_by_key(|i| i["slot"].as_u64()); @@ -8343,7 +8421,7 @@ impl Bus for FakeBus { - [ ] **Step 5: Run the tests to verify they pass** Run: `cargo test -p obsbot-device --test fakes` -Expected: PASS: `test result: ok. 18 passed` (about 1 s: the restart and lag tests sleep). +Expected: PASS: `test result: ok. 19 passed` (about 1 s: the restart and lag tests sleep). Run: `cargo fmt --all --check && cargo clippy -p obsbot-device --all-targets --locked -- -D warnings` Expected: no output. @@ -8678,6 +8756,8 @@ git commit -m "obsbot4linuxd: crate stub with its fixed entry point run(Config)" | every golden file round-trips byte-equal through the proto types | Tasks 1 (types), 4 (transcripts), 10 (descriptions) | | `fake-a` and `fake-b` pass a trait conformance test | Task 9 `fake_a_and_fake_b_pass_the_conformance_check`, Task 7 | | every writable leaf with a readback in a group with a periodic tier | Task 7 (`WritableInTouchedGroup` row) | +| `fake-a`'s `gimbal.move` and `gimbal.turn` are one operation each and leave `tracking.mode` alone (spec 2) | Task 9 `a_move_while_tracking_sends_only_the_move` | +| `preset.save` without `name` is one operation in both fakes, which store an empty name (spec 2, 4.4, CAM-35) | Task 9 `a_preset_saved_without_a_name_gets_none` | | the `cargo metadata` check of section 3 passes | Task 6 | | the four crates as compiling stubs; the 0.13 daemon, client, protocol and CLI removed (the 0.13 `state` module stays in `legacy/` until track A, decision 15) | Tasks 1, 5, 6, 11 | | the Tiny 2 Lite description as data (every field of 4.3 and 4.4, U-rows included) | Task 8 | @@ -8688,7 +8768,7 @@ git commit -m "obsbot4linuxd: crate stub with its fixed entry point run(Config)" ## Self-review -1. **Spec coverage.** Section 3: crates, dependency direction and its CI check (Tasks 1, 5, 6, 11), `schema/` and `descriptions/` and `scripts/regenerate.sh` (3, 10). Section 4.1: every verb, reply, event and error code as a type (1), the ordering rules as transcript checks where a transcript can show them (4); the rest is daemon behaviour (track B). Section 4.2: every Device, Setting and Action field (1), the rules leaves, one meaning per path, naming, absent vs null, `unread`, `raw:`, precision, identity shortening (1, 2, 12). Sections 4.3 and 4.4: every leaf and action (8), checked against the field ledger's rows. Sections 5.1 to 5.3: the enumeration seam `Bus`/`Found`/`Registry` (5) and the fakes' plug, unplug and re-enumeration (9); threads and start-up are track B. Section 5.12: the fakes and the test control (9). Section 6: the client is track C (decision 14); the CLI crate's dependency rule (6). Section 8.1 to 8.3: registry, `trait Camera`, send table types (5). Section 9.3: every row marked (8). Section 10.3 W0: the acceptance table above. Section 11: golden files and stale tests (1, 3, 4, 10). Not in W0 by the spec: daemon behaviour, the transports, the CLI commands, the widget. +1. **Spec coverage.** Section 2 (a request sends exactly what it names): the fakes, Task 9 `a_move_while_tracking_sends_only_the_move` and `a_preset_saved_without_a_name_gets_none`. Section 3: crates, dependency direction and its CI check (Tasks 1, 5, 6, 11), `schema/` and `descriptions/` and `scripts/regenerate.sh` (3, 10). Section 4.1: every verb, reply, event and error code as a type (1), the ordering rules as transcript checks where a transcript can show them (4); the rest is daemon behaviour (track B). Section 4.2: every Device, Setting and Action field (1), the rules leaves, one meaning per path, naming, absent vs null, `unread`, `raw:`, precision, identity shortening (1, 2, 12). Sections 4.3 and 4.4: every leaf and action (8), checked against the field ledger's rows. Sections 5.1 to 5.3: the enumeration seam `Bus`/`Found`/`Registry` (5) and the fakes' plug, unplug and re-enumeration (9); threads and start-up are track B. Section 5.12: the fakes and the test control (9). Section 6: the client is track C (decision 14); the CLI crate's dependency rule (6). Section 8.1 to 8.3: registry, `trait Camera`, send table types (5). Section 9.3: every row marked (8). Section 10.3 W0: the acceptance table above. Section 11: golden files and stale tests (1, 3, 4, 10). Not in W0 by the spec: daemon behaviour, the transports, the CLI commands, the widget. 2. **Placeholder scan.** Every code step holds the whole file or the exact lines; the stubs of Tasks 1, 5 and 11 are complete files whose content later tasks replace. 3. **Type consistency.** The names in every Interfaces block were compiled together in one workspace: `Refusal` lives in `obsbot_proto::validate` from Task 1 on; `Ctx`, `Motion`, `Need`, `Target`, `Group`, `Tier`, `LiveTier`, `SendTable`, `IdentitySource`, `ModelEntry` in `obsbot_device::contract`; `act` takes `&State` everywhere it is called or implemented (Tasks 5, 7, 9); `FakeBus` in `obsbot_device::models::fake`; `Config` in `obsbot4linuxd`. 4. **Review Focus.** Five uncovered input classes, each with its test in the owning task (Tasks 1 and 2). diff --git a/docs/superpowers/specs/2026-11-01-obsbot4linux-1.0-design.md b/docs/superpowers/specs/2026-11-01-obsbot4linux-1.0-design.md index ed7f520..29ad0d7 100644 --- a/docs/superpowers/specs/2026-11-01-obsbot4linux-1.0-design.md +++ b/docs/superpowers/specs/2026-11-01-obsbot4linux-1.0-design.md @@ -1,10 +1,11 @@ # obsbot4linux 1.0: rebuild design -Status: revision 2, draft for review (2026-11-12). Revision 1 (2026-11-01) was agreed with -Thomas in the brainstorming session of that day. Revision 2 applies the design decisions of -2026-11-12. They followed a review of revision 1 against the 0.13.2 code, an audit of that -code, and a second camera model that may arrive soon. Section 15 lists every decision that -changed, one row each, so that each can be overruled on its own. +Status: revision 2 (2026-11-12), approved by Thomas on 2026-11-13. Revision 1 (2026-11-01) +was agreed with Thomas in the brainstorming session of that day. Revision 2 applies the +design decisions of 2026-11-12. They followed a review of revision 1 against the 0.13.2 +code, an audit of that code, and a second camera model that may arrive soon. Section 15 +lists every decision that changed, one row each, so that each can be overruled on its own; +section 15.1 lists the changes made after the approval. Words used with a fixed meaning: @@ -72,10 +73,22 @@ command catalogue. Section 13 lists them. - **The daemon owns the device.** No client touches the camera. - **One contract.** The socket protocol, the CLI's `--json` output and the schema describe the same objects. -- **Camera features only.** The daemon invents nothing. It never writes a setting the user - did not ask for, never switches an automatic control off on its own, and never sends a - command sequence as a workaround. Reporting camera state (on-air probe, target flag) is - fine. +- **Camera features only.** The daemon invents nothing. **A request sends exactly what it + names: the frames of that setting or action, nothing before them and nothing after them.** + The daemon never writes a setting the user did not ask for, never switches an automatic + control or a tracking mode off on its own, and never sends a command sequence as a + workaround (one known exception, the preset list rewind of 8.8, stays until CAM-19). What + the camera then does on its own is camera behaviour: the state shows it as the camera + reports it, and where it is not recorded yet it is a camera item (section 14). Two kinds + of frames belong to a request and are not additions to it: the frames the camera's + protocol defines for the one setting or action asked for (the two bounds of one range, the + block a start-pose action writes back), and the end the request itself set: a + `gimbal.turn` without `keep:true` lasts as long as its connection, so its stop is sent + when that connection closes, a daemon shutdown included (5.6, 5.9). How the model encodes + a value in its frame so that it keeps the units of 4.2 (the sign of the tilt, the yaw sign + of a turn, 4.4) is part of that frame. Reads of the camera's state are not commands. + Clients follow the same rule: the CLI and the widget never chain a second request to the + one the user made. Reporting camera state (on-air probe, target flag) is fine. - **Nothing around the camera.** The daemon keeps no file: no state, no names, no keys, no remembered settings, no migration. What the camera cannot do or report is not there. - **Evidence first.** Only backed fields enter a released description (section 8.5). @@ -150,6 +163,9 @@ decided with M5. | `state.rs` (remembered values) | – | drop: every value is read from the camera | | byte-exact tests, recordings | `obsbot-device` tests, fixtures (section 8.5) | carry over | +Carrying the logic excludes what section 2 forbids: the carried gimbal move and rate flows +lose their switch to the static tracking mode (15.1 row 88). + Carried code comes over with the fixes the audit of 0.13.2 asked for. Fixes that need no camera land on `main` first, in the 0.13 layout, so that the groundwork move carries them (section 10.2, stage G). Fixes whose acceptance needs the camera are camera-session items @@ -518,7 +534,8 @@ Per group: store it, and `false` stops the hand tracker's gimbal moves. `hand.return` is a pose or `null` (nullable: the camera reports "not set"). - `presets`: at most 3 (`limits.presets`), slots 0–2. `views`: no known maximum. Names are - at most 32 printable ASCII characters. + at most 32 printable ASCII characters. A preset's `name` is what the camera reports, and + may be empty (4.4, CAM-35). - `exposure.mode`: `auto|manual`. `exposure.region`: `face|global`, the metering while the exposure is automatic. `exposure.time` in µs; it `requires` `exposure.mode=manual` (a time written while automatic freezes the automatic exposure); the camera rounds down to its own @@ -604,18 +621,27 @@ factory_reset {confirm} - `gimbal.turn`: at least one of `pan_speed` and `tilt_speed` (°/s, up to 178); a missing one is 0. It keeps turning until `gimbal.stop` or speed 0. It stops when the issuing connection closes, unless `keep:true`; the CLI sends `keep:true`, so a one-shot call - works as `gimbal rate` did in 0.13. The daemon sends the stop when the camera falls asleep - while a turn runs, and best effort on a clean daemon shutdown (section 5.9). -- `gimbal.move` and `gimbal.turn` switch `tracking.mode` to `static` before they move (the - tracking would move the gimbal back otherwise); the state after the reply shows the new - mode. `zoom.move`, `zoom.stop` and `gimbal.stop` leave the tracking mode alone. + keeps turning as `gimbal rate` did in 0.13. The daemon sends no stop of its own beyond + that (section 2): a `keep:true` turn goes on across a daemon shutdown (5.9), and what a + turn does when the camera falls asleep is the camera's (CAM-7). `pan_speed` has the + units of 4.2 whatever `image.mirror` and `gimbal.invert` say: the camera reverses the yaw + of a turn when exactly one of them is on, and the model encodes the speed so that a + positive `pan_speed` turns the camera to its left, as a positive `pan` of `gimbal.move` + does (field ledger, measured). +- `gimbal.move` and `gimbal.turn` send only the move (section 2). They leave `tracking.mode` + alone, as `zoom.move`, `zoom.stop` and `gimbal.stop` do, and they are not refused while the + tracking is on. What the camera does with a manual move while it tracks is CAM-34; the + state after the reply shows what the camera reports. A client that wants the tracking off + first sends `set tracking.mode=static` itself, as its own request. - `zoom.move`: `speed` is `slow|medium|fast` (`slow` and `fast` are U-12). - `preset.save`, `view.save` and `power_on.use_current` without `pose`, and `zone.return_here` and `hand.return_here`, take the live pose and the current zoom factor from the camera. While the camera sleeps its live pose is the parking pose, which lies outside the tilt range, so these actions are `asleep: ignored` until CAM-22 shows otherwise. -- `preset.save` without `name` names the preset `Preset ` (0.13.2 behaviour). +- `preset.save` without `name` sends the preset and no name: no rename frame follows, and + obsbot4linux invents no name (0.13.2 named it `Preset `). What the camera then + reports as the name of an unnamed preset is CAM-35; the state shows it as reported. `view.save` needs a name. Names are validated before the first frame is sent. - `preset.recall`, `preset.rename`, `preset.delete` and `power_on.use_preset` on an empty slot, and `view.*` with an unknown id, are refused with `conflict`. So are these actions @@ -767,7 +793,8 @@ requests for it at once (4.1 rule 2): `get` from the fixed state, `set` and `do` - Each worker owns one device queue (FIFO, at most 32 requests; more is `busy`) and applies the queue rules of 4.1 rule 9. - **Write path:** validate against the description and the current state (`invalid`, - `unsupported`, `out_of_range`, `conflict`, `asleep`) → send in the model's apply order → + `unsupported`, `out_of_range`, `conflict`, `asleep`) → send the request's frames and no + others (section 2), in the model's apply order → re-read the groups the model marks as touched by the written leaves and actions → publish (new rev, events) → reply. - After the re-read, each written leaf with `readback:true` either shows the written value @@ -797,8 +824,8 @@ requests for it at once (4.1 rule 2): `get` from the fixed state, `set` and `do` - **Continuing motion.** The worker records which connection started an action the model marks as continuing (on the Tiny 2 Lite `gimbal.turn`). Any other motion action of that device clears the record. When the recorded connection closes without `keep:true`, the - worker queues the model's stop for it. When `asleep` turns true while a continuing action - is recorded, the worker sends the stop. + worker queues the model's stop for it. The worker sends no other stop of its own: not when + `asleep` turns true, and not for a `keep:true` turn (section 2). ### 5.7 Polling and asleep @@ -851,8 +878,10 @@ bounds each control transfer; its value is camera item CAM-29. holds the same lock file, so a 1.0 daemon started beside it exits with the lock message. A socket-activated 0.13 daemon takes no lock; it runs as `obsbot4linux.service` until the upgrade steps of 12.2 stop it. -- **Shutdown.** On SIGTERM or SIGINT the daemon sends the stop of an active continuing - action (best effort) and exits 0. A crash leaves the camera as it is. +- **Shutdown.** On SIGTERM or SIGINT the daemon closes its connections, so a continuing + action started without `keep:true` gets its stop (best effort, 5.6), and exits 0. A + `keep:true` turn goes on, and a crash leaves the camera as it is; `gimbal.stop` ends a turn + from any connection, after a restart too. - **Failures.** A panic in a worker, the registry thread or the acceptor ends the process with a non-zero code, and systemd restarts it. A panic in a connection thread closes only that connection. Locks are taken poison-tolerant. @@ -988,6 +1017,11 @@ obsbot4linux watch [--live …] hello reply and events as JS `describe ` for a path or action not in the device's description exits 2 with code `unsupported`. +- **Preset names.** `preset save N` without `--name` sends no name, and the CLI never makes + one up. For a preset whose `name` is empty, `preset list` and `status` may print a + placeholder of their own (for example `(unnamed)`) as display only; no client writes such + a label to the camera, and `--json` prints the name as the camera reported it. + - **Errors.** With `--json`, an error prints `{"error":{"code","message"}}` on stdout; help and version stay plain text. An error the daemon sent keeps its code. Errors the CLI finds itself use these codes; `usage`, `ambiguous_device`, `no_device` and `daemon` exist @@ -1023,6 +1057,9 @@ obsbot4linux watch [--live …] hello reply and events as JS waits for the reply; Ctrl-C closes the connection, which drops a request the daemon has not started (4.1 rule 9). - **`gimbal.turn`**: the CLI always sends `keep:true` with it. +- **One request per command.** Every command sends the one request it maps to and chains no + other (section 2): `do gimbal.move` and `do gimbal.turn` leave the tracking as it is, and a + user who wants it off first runs `set tracking.mode=static`. - **Completions** for bash and zsh cover subcommands, flags, `get`/`describe` paths, `set` paths with `=`, enum values after `=`, action names and their parameters. They are generated from the CLI definition and the embedded description files @@ -1082,6 +1119,10 @@ pub trait Camera: Send { } ``` +`touched` names the groups whose values the camera itself changes in response to the leaf or +action, the leaf's own group included. A request sends only what it names (section 2), so no +group is touched because of a frame the client did not ask for. + The signatures are fixed in stage W0 and change only by a contract-change PR. The fake models implement the same trait, so it is designed against two shapes from the start. The model owns its frame codec choice: the family codec when the family's codec tests pass on @@ -1104,7 +1145,8 @@ its module otherwise. A codec moves into `family` only when a second model uses - No raw send path exists anywhere: not in the daemon, the CLI, the protocol or the fakes. Public code and docs name only what is sent. - A test drives every setting and action of each model at its boundary values on the replay - transport and asserts that every frame is in the send table. + transport and asserts that every frame is in the send table, and that each setting and + action sends exactly the frames of its ledger row, in that order (section 2). - **No probing.** Capabilities come from the model's code, never from trying ids. The extension unit is found by the GUID its registry entry declares, in the USB descriptor (read from sysfs), never by a request. @@ -1315,8 +1357,10 @@ M4 is the first model that completes these steps. exposure, image, focus, audio, LED, sleep). 60 s: zone, hand, power-on, views. `touched`: presets (read at plug-in and after `preset.*` and `power_on.use_preset`; the preset read writes to the camera to rewind its list, which the vendor app never does, - CAM-19). While asleep only the status block is read. This plan is carried from 0.13.2; - the vendor-app rate rule of 8.7 step 4 applies to models onboarded under 8.7. + CAM-19; this rewind is the one known exception to section 2's "no command sequence as a + workaround" and stays only until CAM-19 decides). While asleep only the status block is + read. This plan is carried from 0.13.2; the vendor-app rate rule of 8.7 step 4 applies to + models onboarded under 8.7. - **Apply order** inside a patch: a setting's `requires` paths before the setting (`exposure.mode=manual` before `exposure.time`; `image.white_balance.auto=false` before `image.white_balance.temperature`; `focus.mode=manual` before `focus.position`), @@ -1324,9 +1368,9 @@ M4 is the first model that completes these steps. between `exposure.anti_flicker` and `exposure.mode` is CAM-13. - **Touched groups:** `tracking.mode` touches tracking and framing; `zoom.field_of_view` and the gesture settings touch zoom; `preset.recall` touches zoom and the live pose; - `gimbal.move` and `gimbal.turn` - touch tracking and framing when they switched the tracking mode; `gimbal.stop`, - `zoom.stop` and `zoom.move` touch nothing. + `gimbal.move`, `gimbal.turn`, `gimbal.stop`, `zoom.stop` and `zoom.move` touch nothing. + The moves touch the tracking group only if CAM-34 shows that the camera changes the + tracking mode itself on a manual move; until it is recorded they do not. - **Asleep:** `zoom.field_of_view` is `ignored` (measured); `power_on.go` is `ignored` (measured, #79: no move, no error); `wake` is `accepted` (capture 13). The pose-taking actions of 4.4 are `ignored` until CAM-22. The readbacks of `audio.volume` @@ -1396,10 +1440,10 @@ exception (9.2). 1.0 is released only when every row is verified on the real Tin | `zoom F` | `set zoom.factor=F` | | | `zoom F --speed S` | `do zoom.move factor=F speed=slow\|medium\|fast` | 10/30/50; U-12; other S is PX-6 | | `zoom stop` | `do zoom.stop` | | -| `gimbal [--pan P] [--tilt T]` | `do gimbal.move [pan=P] [tilt=T]` | a missing axis keeps its position | -| `gimbal … --speed S` | `do gimbal.move … speed=S` | U-9 | +| `gimbal [--pan P] [--tilt T]` | `do gimbal.move [pan=P] [tilt=T]` | a missing axis keeps its position; the tracking stays as it is (12.2) | +| `gimbal … --speed S` | `do gimbal.move … speed=S` | U-9; the tracking stays as it is (12.2) | | `gimbal stop` | `do gimbal.stop` | | -| `gimbal rate [--pan P] [--tilt T]` | `do gimbal.turn [pan_speed=P] [tilt_speed=T]` | the CLI sends `keep:true` | +| `gimbal rate [--pan P] [--tilt T]` | `do gimbal.turn [pan_speed=P] [tilt_speed=T]` | the CLI sends `keep:true`; the tracking stays as it is (12.2) | | `gimbal center X Y` (hidden) | – | PX-1 | | `fov wide\|medium\|narrow` | `set zoom.field_of_view=…` | | | `led N` | `set led.brightness=N` | | @@ -1464,7 +1508,7 @@ exception (9.2). 1.0 is released only when every row is verified on the real Tin | `image autofocus 0\|1` | `set focus.mode=manual\|continuous` | as `focus auto`; CAM-14 | | `image gamma V` | – | PX-2 | | `preset list` | `get presets` | | -| `preset save N` | `do preset.save slot=N` | live pose, name `Preset ` | +| `preset save N` | `do preset.save slot=N` | live pose; no name (12.2, CAM-35) | | `preset save N --name S` | `do preset.save slot=N name=S` | | | `preset save N --pan P --tilt T --zoom Z` | `do preset.save slot=N pose=P,T,Z` | | | `preset rename N S` | `do preset.rename slot=N name=S` | | @@ -1637,8 +1681,9 @@ rewrite runs as W0, then W1 (four parallel tracks A–D), then W2, W3 and W4. test passes; every golden file round-trips byte-equal through the proto types; `fake-a` and `fake-b` pass a trait conformance test (every described leaf readable; every writable leaf shows a valid written value after the touched re-read, or enters `pending`; every - writable leaf with a readback in a group with a periodic tier, 5.7); the - `cargo metadata` check of section 3 passes. + writable leaf with a readback in a group with a periodic tier, 5.7); `fake-a`'s + `gimbal.move` and `gimbal.turn` are one operation each and leave `tracking.mode` alone + (section 2); the `cargo metadata` check of section 3 passes. - **Acceptance with camera:** none. **Track A: device** @@ -1657,10 +1702,11 @@ rewrite runs as W0, then W1 (four parallel tracks A–D), then W2, W3 and W4. decoder against recorded answers; the status decoder against recorded blocks; angle conversions as pure functions tested with a recorded answer and one asymmetric case; the send-table test; the registry test; the serial-format test; each audit defect in carried - code fixed or named in section 14. + code fixed or named in section 14; the carried gimbal move and rate flows without their + switch to the static tracking mode (3.2). - **Acceptance with camera (CS-1):** the live tests: CAM-1 to CAM-8, CAM-10, CAM-13 to - CAM-20, CAM-22, CAM-23, CAM-25 and CAM-33 (CAM-21 is recorded if it happens and gates - nothing). + CAM-20, CAM-22, CAM-23, CAM-25, CAM-33 to CAM-35 (CAM-21 is recorded if it happens and + gates nothing). Results go into the ledger and arrive as contract-change PRs (U-rows removed, `settle_ms`, `asleep` values, `focus auto off` mapping). @@ -1689,7 +1735,11 @@ rewrite runs as W0, then W1 (four parallel tracks A–D), then W2, W3 and W4. nothing sent after it until the device goes; a device-number change handled as unplug plus plug; the queue of an unplugged device answered and a new worker on re-plug; a re-added `port:` entry starting at rev 1; the wake read of `unread` groups and the retry of a - failed `touched` read; an identity series ending at the first complete answer. + failed `touched` read; an identity series ending at the first complete answer; a `set` + causes at most one `apply` per leaf of its patch and no `act`, a `do` exactly one `act` + and no `apply`, and apart from reads nothing else reaches the fake for that request + (section 2); no stop reaches the fake when it falls asleep during a turn, nor for a + `keep:true` turn at shutdown (5.6, 5.9). - **Acceptance with camera:** none in this track (W4 covers the real path). **Track C: client and CLI** @@ -1707,7 +1757,8 @@ rewrite runs as W0, then W1 (four parallel tracks A–D), then W2, W3 and W4. `hello` reply gives exit 4 and the restart message; no socket gives exit 4 and the `systemctl` line; end-to-end snapshots against an in-process `obsbot4linuxd --fake fake-a,fake-b,unknown`; every row of 9.1 turned into its request - against `fake-a`; the completions stale test. + against `fake-a`, the gimbal rows reaching the fake as one `act` each and no `apply`; the + completions stale test. - **Acceptance with camera:** none in this track. **Track D: widget (repository `obsbot4linux-dux`)** @@ -1721,7 +1772,8 @@ rewrite runs as W0, then W1 (four parallel tracks A–D), then W2, W3 and W4. and list limits taken from the description, `pending` values shown, live topics only while the popup is open, markers for `untested` and `unsupported`, the exit-4 hint, and the defaults reset built from `default` values (a `set` patch; which controls it covers is - the widget's product choice). Also widget 0.28.0, the last 0.x release, which refuses + the widget's product choice). Each control sends its one request and chains none (section + 2, 12.3). Also widget 0.28.0, the last 0.x release, which refuses obsbot4linux ≥ 1.0 (`plugin.json`: `obsbot4linux >= 0.7.3, < 1.0`). - **Owner files:** the widget repository. - **Depends on:** W0 (golden transcripts); B-1 for live tests. @@ -1730,7 +1782,8 @@ rewrite runs as W0, then W1 (four parallel tracks A–D), then W2, W3 and W4. controls; its controls offer `fake-b`'s own enum values and bounds and its preset count, not the Tiny 2 Lite's; the unsupported device shows as in 8.4 step 3 and disappears on unplug as in step 7; a value set with the CLI appears - in the widget at once (`pending`); widget 0.28.0 is released before the 1.0 release + in the widget at once (`pending`); a gimbal control sends one request (checked against the + fake's operation log or by review); widget 0.28.0 is released before the 1.0 release candidate. - **Acceptance with camera:** in W4. @@ -1751,7 +1804,8 @@ rewrite runs as W0, then W1 (four parallel tracks A–D), then W2, W3 and W4. - **Content:** two-binary packages (12.1) with units (`ExecStart` = `obsbot4linuxd`, `NoNewPrivileges=yes`), completions and the schema; the release workflow publishes a tag `v1.0.0-rc.N` as a pre-release; the Tiny 2 Lite set to `support: tested` (8.8); CHANGELOG - with the migration notes; README support table. + with the migration notes; README support table, and the README without the 0.13 statements + that the gimbal moves switch the tracking to static first (section 2, 15.1 row 88). - **Owner files:** `.github/`, `packaging/` (except completions), `CHANGELOG.md`, `README.md`, and the support level in the `tiny2lite` registry entry. - **Depends on:** W2. @@ -1883,13 +1937,25 @@ The CHANGELOG of 1.0 carries the migration notes: clients no longer start one; - `$XDG_STATE_HOME/obsbot4linux/` (it held `state.json` and `daemon.log`) is no longer used and can be deleted. Nothing is imported, and nothing set on the camera is lost: the camera - stores it. + stores it; +- the 0.13 commands `gimbal [--pan P] [--tilt T]` and `gimbal rate` switched the tracking + to static before they moved. `do gimbal.move` and `do gimbal.turn` send only the move and + leave the tracking as it is (section 2). A script that relied on the switch runs + `obsbot4linux set tracking.mode=static` first, as its own step; +- the 0.13 command `preset save N` without a name saved the preset as `Preset `. + `do preset.save slot=N` sends no name (section 2): what the camera shows for such a preset + is what the state shows, and a script that wants the old names passes `name=…` itself. ### 12.3 Widget Track D (section 10.3) defines widget 1.0 and the last 0.x release. Widget 1.0 settles values by `pending` and `rev`; this replaces its own hold of up to 3 s -(obsbot4linux-dux#89). Its `plugin.json` requires obsbot4linux ≥ 1.0. +(obsbot4linux-dux#89). Its `plugin.json` requires obsbot4linux ≥ 1.0. Each control sends +the one request the user made and chains none (section 2): the gimbal controls send +`gimbal.move` or `gimbal.turn` alone, never a tracking switch before them. A user who wants +the tracking off uses the tracking control first. Saving a preset sends no name unless the +user typed one. A control may show a placeholder of its own for a preset whose `name` is +empty, as pure display; it never writes that label to the camera. ### 12.4 Desktop app @@ -1940,7 +2006,7 @@ and CAM-29 are recorded only if they happen and gate nothing. | CAM-4 | During a speed-limited `zoom.move`, does the zoom readback report the target or the current factor? | A | text of `docs/protocol-2.md` | | CAM-5 | `settle_ms` per leaf (measured so far: zone tracking 1–3 s, target lock about 1 s) | A | `settle_ms` per leaf | | CAM-6 | Do `hand.move_gimbal` and `led.blink` survive sleep and wake, re-plug, factory reset? | A | the `readback:false` lifetime (wake added if they are dropped) | -| CAM-7 | Does a running `gimbal.turn` stop when the camera falls asleep? Does it resume after wake? | A | documentation; the daemon's stop on sleep stays | +| CAM-7 | Does a running `gimbal.turn` stop when the camera falls asleep? Does it resume after wake? | A | documentation; clients send `gimbal.stop` themselves | | CAM-8 | Is the USB audio interface absent on a cold plug with the microphone off? Does a status-block value follow the microphone? | A | U-23 | | CAM-9 | `factory_reset` while asleep; does the device return under the same `sn:` id? | W4 | `asleep` of `factory_reset`; parity row | | CAM-10 | How long do a cold read of all groups and a read of the 5 s tier take? | A | documented `hello` latency; margin to `CLIENT_REPLY_TIMEOUT` | @@ -1967,6 +2033,8 @@ and CAM-29 are recorded only if they happen and gate nothing. | CAM-31 | Does the camera keep its USB product id when it renames itself or changes mode (#90)? | after 1.0 | registry entries | | CAM-32 | The status block of a real Tiny 2: do Tiny 2 and Tiny 2 Lite share the layout? (an unverified hypothesis today) | when a Tiny 2 is at hand | onboarding of the Tiny 2 | | CAM-33 | Do `tracking.speed`, `tracking.profile=headroom` and `framing.*` change the tracking visibly, with a person in view? (Their write and readback are measured, #98.) | A | `no_effect` of those leaves | +| CAM-34 | With the tracking on (`tracking.mode` not `static`), what do `gimbal.move` and `gimbal.turn` do: move and track on, switch the mode, or nothing? (with a person in view) | A | the text of 4.4; `touched` of the moves (8.8) | +| CAM-35 | What does the camera report as the name of a preset saved without a name, and does the name reach the state as empty or as a name of its own? (obsbot4linux sends none) | A | the text of 4.4; the placeholder of clients (6.2, 12.3) | **Tiny 3 and Tiny 3 Lite** (stage M4, onboarding steps 0–2 and 7 on the unit in hand; everything here is unknown until captures) @@ -2086,3 +2154,14 @@ them and are not listed. | 85 | On-air level | ledger level `measured` | level `daemon`: a Linux fact, given to every supported model, outside the vendor-app rate rule (8.5, 8.7) | it cannot be captured, and no check row measures a read-only leaf | the probe on the Tiny 2 Lite (measured, 2026-10-25) | editor | | 86 | Destructive actions in onboarding | – | the maintainers' step 7 adds the captured destructive actions and runs `--destructive`; an owner's run leaves it out; a released `untested` description holds none (8.6, 8.7) | a model could become `tested` without its restart and destructive rows | none: design choice | editor | | 87 | Readbacks the vendor app never polls | – | a writable leaf whose captured readback the idle poll never reads takes the 60 s tier (8.7 step 4) | 5.7 needs a periodic tier for `pending` to clear | none: design choice | editor (D2) | + +### 15.1 Changes after approval + +Revision 2 was approved on 2026-11-13. Each later change has one row; each can be overruled +on its own. + +| # | Date | Topic | Approved revision 2 | Now | Why | Source | +| --- | ---------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| 88 | 2026-11-13 | An action sends exactly what was asked | `gimbal.move` and `gimbal.turn` switch `tracking.mode` to `static` before they move, carried over from 0.13 | they send only the move and leave `tracking.mode` alone; section 2 states the rule for every request and names the two kinds of frames that belong to one; the camera's answer to a manual move while it tracks is CAM-34; clients chain no request; the carried flows lose the switch (2, 3.2, 4.4, 5.6, 6.2, 8.2, 8.3, 8.8, 9.1, 10.3, 12.2, 12.3, 14); new tests: 8.3 the exact frames of each ledger row; 10.3 the W0 fake test, the track B, C and D checks | Thomas asked to keep the daemon free of logic of its own; the carried-over switch was such logic and contradicted section 2, so it is gone | Thomas, 2026-11-13 | +| 89 | 2026-11-13 | Stops the daemon sends on its own | the daemon stops a running turn when the camera falls asleep, and best effort on a clean shutdown (also for a `keep:true` turn) | the only stop the daemon sends is the end a turn's request set: a turn without `keep:true` stops when its connection closes, a shutdown included; a `keep:true` turn goes on across a shutdown, and across sleep the camera decides (CAM-7) (2, 4.4, 5.6, 5.9, 10.3, 14) | the client that started a turn can still end it, and `gimbal.stop` works from any connection and after a restart, so these stops were logic of our own; 0.13 sent neither | Thomas, 2026-11-13 (the rule of row 88) | +| 90 | 2026-11-13 | Presets saved without a name | `preset.save` without `name` names the preset `Preset ` (0.13.2 behaviour) | it sends the preset and no name, no rename frame follows; what the camera reports as the name of an unnamed preset is CAM-35; a client may show its own placeholder for an empty name as display only and never writes it to the camera (2, 4.3, 4.4, 6.2, 9.1, 10.3, 12.2, 12.3, 14); new test: 10.3 the W0 fake test for `preset.save` | a name the daemon invents is a write the user did not ask for and contradicts section 2; the camera can show an unnamed preset by itself | the rule of row 88, applied 2026-11-13 |