From a24e22a52de9ffe6b708e13606324bf4b8b19fc8 Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Thu, 12 Nov 2026 21:42:58 +0100 Subject: [PATCH 01/27] Revise the 1.0 design spec (revision 2) Applies the design decisions of 2026-11-12: typed envelopes with a generic path-keyed payload, four crates, a path-keyed trait Camera with an id-free daemon, pending values with settle bounds, a sysfs scan, unsupported devices that receive nothing, per-model send tables, fake models of two shapes, and a groundwork stage released from main. Adds the onboarding procedure, the full 0.13.2 parity table with named exceptions, the Tiny 2 Lite fields without backing and their fate, the stage plan with acceptance criteria, the camera-only questions, and a table of every change from revision 1. Co-Authored-By: Claude Opus 5.5 --- .../2026-11-01-obsbot4linux-1.0-design.md | 1794 +++++++++++++++++ 1 file changed, 1794 insertions(+) create mode 100644 docs/superpowers/specs/2026-11-01-obsbot4linux-1.0-design.md 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 new file mode 100644 index 0000000..3609ff9 --- /dev/null +++ b/docs/superpowers/specs/2026-11-01-obsbot4linux-1.0-design.md @@ -0,0 +1,1794 @@ +# 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. + +Words used with a fixed meaning: + +- **captured**: the bytes are in a recording of the vendor app talking to the camera + ("recorded from the vendor app"). Cited as "capture N". +- **measured**: written from Linux and read back on the camera, or its effect observed + ("measured on the camera"). Cited with a date, a public issue or a camera-session item. +- **backed**: a setting whose write and readback, or an action whose frames and effect, are + captured or measured on that model. A standard UVC control counts as measured once V4L2 + reads back the written value on the camera. "0.13 uses it" is not a level. +- **verified / unverified / unknown**: the status of a statement. Unknown means only a + device or a capture can settle it. +- **must**, **never**: requirements. Every requirement in this document can be checked by a + test, a review of the diff, or a camera-session item (section 14). + +## 1. Goal + +obsbot4linux 1.0 replaces the 0.13 daemon, protocol, state model and CLI with a clean +structure and no legacy baggage. The camera knowledge earned so far is carried over: frame +codec, command bytes, status decoding, recordings and byte-exact tests. The daemon, the +protocol, the client library and the CLI are written new. + +Success criteria (the release gate is section 10.6): + +1. Every command and option of 0.13.2 has a row in the parity table (section 9.1). Each row + is verified on a real Tiny 2 Lite, or is a named parity exception with its reason + (section 9.2). +2. No setting or action is offered on a guess. A released description holds only backed + fields. Section 9.3 lists the Tiny 2 Lite fields that are not backed today and the fate + of each. +3. The CLI, the widget and the desktop app speak one versioned protocol, protocol 2: typed + envelopes, a generic path → value payload, a JSON schema and one description file per + model. +4. Several cameras at once, hotplug, and a device identity that survives re-plugging. +5. **Guarantee A.** A new model needs no change to `obsbot-proto` (types and client library) + or to the CLI. Hand-made UIs (widget, desktop app) keep working for the settings they + know, gate every control on the device description, and need an update only to show + settings that are new to them. 1.0 has no generated UI. +6. A camera with a known OBSBOT vendor id that no model covers is listed as unsupported and + receives nothing (section 8.4). +7. CI tests the daemon, the client and the CLI without a camera, with two fake models of + different shape and one unknown device running together (section 5.12). + +Out of scope for 1.0: a second camera model (M4), the device lease for the firmware updater +(M5), network cameras, the desktop app (own repository, own spec), a generated UI, and any +command catalogue. Section 13 lists them. + +## 2. Principles + +- **Clean over compatible.** The user base is very small. Breaking changes come with a + transition (release notes, minimum versions, migration notes), not by carrying old + structure. There is no protocol-1 adapter. +- **The camera is the truth.** Byte-exact frame tests and recordings are kept: they encode + what the camera understands, not our design. +- **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. +- **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). +- **No probing.** A capability is never discovered by trying ids. A model sends only the + frames in its send table (section 8.3). A device that no model covers receives nothing. A + blind query scan once broke the camera's query channel until a replug (measured). +- **Unknown until captures.** Nothing about a model without a unit in hand is stated as a + device fact. This holds for the Tiny 3 and the Tiny 3 Lite (section 8.9). + +## 3. Architecture + +Cargo workspace, one repository, one release, four crates: + +``` +crates/obsbot-proto protocol 2: envelopes, error codes, description types (Device, + Setting, Action), validator, path helpers, schema generation, and + the synchronous client. Depends on serde, serde_json, schemars. +crates/obsbot-device transport, family, models/{tiny2lite, fake}, registry, + enumeration. Depends on obsbot-proto for the description types. +crates/obsbot4linuxd daemon binary; also a library with run(Config) for tests. +crates/obsbot4linux CLI binary, a pure client. +descriptions/ .json: the description of each real model (generated); +descriptions/fake/ .json: the descriptions of the fake models (generated) +schema/protocol-2.json generated from obsbot-proto +packaging/ arch, deb, rpm, systemd units, completions +docs/ protocol-2.md; field-ledger.md (Tiny 2 Lite); + field-ledger-.md for each further model +scripts/check-model.sh live check of one device against its description (section 9.4) +``` + +Dependency direction: proto ← device ← daemon; proto ← CLI. The `[dependencies]` of the CLI +crate never contain `obsbot-device` or `obsbot4linuxd`; a CI step checks this with +`cargo metadata`. Its `[dev-dependencies]` may contain `obsbot4linuxd`, so that tests run a +fake daemon in-process. All crates take `version`, `rust-version` and +`license = "GPL-3.0-or-later"` from `[workspace.package]`. + +The firmware updater is a separate, private project. Whether it joins the workspace is +decided with M5. + +### 3.1 Layers inside obsbot-device + +1. **transport**: `trait Transport`: extension-unit get, set and length by selector, UVC + control ioctls, and the descriptor facts read from sysfs. One implementation talks to USB + UVC devices. A replay implementation answers from recorded exchanges for tests. +2. **family**: shared building blocks, not a driver: the frame codec (framing, CRC, sequence + numbers), answer matching, short-command write, status-block read, UVC helpers, the on-air + probe, and encoders and decoders that are byte-identical across models. A model uses a + shared encoder only where that model's own evidence covers it. +3. **models/**: one module per model. It implements `trait Camera` (section 8.2). A + model is code plus const tables (ids, offsets, enum tables, ranges) plus its own flows. + `models/fake` holds the fake models (section 5.12). + +`registry` maps USB (vendor id, product id) pairs to model keys (section 8.1). +`enumeration` reads sysfs and returns the USB facts of every video device (section 5.3). + +### 3.2 Carry-over (0.13.2 → 1.0) + +| 0.13.2 | 1.0 | Decision | +| -------------------------------------------- | ------------------------------------------------- | ----------------------------------------------- | +| `frame.rs` (frame, CRC, answer parser, seq.) | `obsbot-device/family` | carry over; the clock-seeded sequence stays | +| device access in `device.rs`, `camera.rs` | `obsbot-device/transport` | carry over, regroup; the extension unit by GUID | +| `commands.rs` byte builders, setting enums | `models/tiny2lite`; `family` where byte-identical | carry over | +| `status.rs`, `presets.rs` parsers | `models/tiny2lite` | carry over | +| `streaming.rs` (on-air probe) | `obsbot-device/family` | carry over | +| `models.rs` (detection by name strings) | `obsbot-device/registry` and `enumeration` | rewrite: USB ids from sysfs | +| `camera.rs` sequencing (Tiny 2 Lite flows) | `models/tiny2lite` behind `trait Camera` | carry over the logic, rewrite the shape | +| `camera.rs` `current_pose()` | – | not carried: it reads a remembered or V4L2 pose | +| `daemon.rs`, `proto.rs`, `client.rs` | `obsbot4linuxd`, `obsbot-proto` | rewrite | +| `cli.rs` | `obsbot4linux` | rewrite | +| `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 | + +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 +(section 14). Track A closes the list: each audit defect in carried code is fixed in +`obsbot-device` or named in section 14. No known defect is copied into 1.0. The defects +that need no camera: + +- the answer poll treats every read error as "not ready", so an unplug shows as "no + answer" after 1 s instead of failing at once; +- the image setter writes the white-balance temperature and the absolute focus while their + automatic control is on (1.0: `requires`); +- the on-air state and its applications are read at two moments (1.0: one sample); +- a short preset list counts as a successful read; +- "the current pose" is a remembered or V4L2 pose (1.0: the live pose); +- `preset save` sends the pose before it validates the name (1.0: 4.1 rule 5). + +The camera-gated ones are U-8, U-9, U-18, CAM-18, CAM-19 and CAM-20. + +### 3.3 Licence + +Every crate is `GPL-3.0-or-later`. Files that carry an EUPL-1.2 Article 5 note keep it, and +the note follows their code: every 1.0 file that receives code from such a file carries the +note too, including model modules copied from another model. + +## 4. Protocol 2 + +`docs/protocol-2.md` is the client reference: per-message schemas, a worked sequence with +`pending`, lifecycle diagrams and a multi-device transcript. Its normative part is written +in stage W0 from this section; stage W2 adds the worked examples. The evidence for each +field of a model is in that model's ledger (`docs/field-ledger.md` for the Tiny 2 Lite). + +### 4.1 Messages + +JSON lines over the Unix socket `$XDG_RUNTIME_DIR/obsbot4linux.sock` (mode 0600; the +environment variable `OBSBOT4LINUX_SOCKET` overrides the path). One JSON object per line, +UTF-8. A request line longer than 64 KiB closes the connection. Lines from the daemon have no +length limit, and clients must not cap them. The daemon accepts at most 64 connections; a +further connection is closed at once. + +**Requests** carry `id` (integer or string, echoed verbatim) and exactly one verb: + +| Verb | Shape | Reply `ok` | +| ------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `hello` | `{"id","hello":{"protocol":2,"client":"","subscribe":bool,"live":[…]}}` | `{"protocol":2,"daemon":"","devices":[…],"states":{"":{"rev","state"}…}}` | +| `get` | `{"id","device","get":[""…]\|null}` | `{"rev","state"}`: the full state, or only the paths asked for | +| `set` | `{"id","device","set":}` | `{"rev"}` | +| `do` | `{"id","device","do":"","with":{…}}` | `{"rev","result"?}` | +| `live` | `{"id","live":[""…]}` | `{}`; replaces the connection's live list | + +`subscribe` defaults to `true` and `live` to `[]`. The first message on a connection must be +`hello`. A second `hello` is answered `invalid`. A patch is an object of leaf paths; it may +nest them (`{"zone":{"area":{"pan":…}}}`) or use dotted keys (`{"zone.area.pan":…}`). + +**Replies:** `{"id":,"ok":{…}}` or `{"id":,"error":{…}}`. + +**Events** (only on connections with `subscribe:true`): + +- `{"event":"state","device","rev","state"}`: the full state of one device. +- `{"event":"devices","devices":[…]}`: the full device list. It is sent whenever an + entry is added, removed or changed (availability, identity hand-over, firmware, video + node). + +On unplug of a device with an `sn:` id the daemon sends the `devices` event first, then one +`state` event that carries `available:false` and the last known values; no further state +events follow until the device is back. On re-plug the order is the same: `devices`, then +`state`. A `port:` entry is removed on unplug: the `devices` event no longer lists it, and +no state event follows. + +**Errors:** `{"id","error":{"code","message","rev"?,"applied"?,"failed"?,"supported"?}}`. +`message` is always present (English, for logs). `rev` is present whenever the daemon +re-read the device before answering. + +| Code | When | Extra fields | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `protocol` | the first message is not `hello`, or its protocol is not supported (the connection is closed; `id:null` if there was none) | `supported:[2]` | +| `invalid` | malformed request, no verb or two verbs, wrong value type, a read-only path, a second `hello`, a destructive action without `confirm:true` | – | +| `unknown_device` | no device with this id | – | +| `unsupported` | well-formed, but the path or action is not in this device's description (a typo included) | – | +| `out_of_range` | the value lies outside the description (bounds, `writable`, string length) | – | +| `conflict` | valid, but the device's current state does not allow it: a `requires` condition not met, an empty preset slot, an unknown view id, no free view | – | +| `unavailable` | the device is unplugged | – | +| `asleep` | the camera is asleep and the description marks the setting or action `asleep: ignored` | – | +| `busy` | 32 requests are already queued for the device (the lease of M5 will use it too) | – | +| `camera` | the camera refused or did not answer within the transport timeouts (section 5.8), the device node stayed locked, or the 15 s deadline passed | `rev`, `applied:[paths]`, `failed:""` | +| `internal` | a daemon bug | – | + +**Ordering, `rev` and `pending` (normative):** + +1. `rev` is per device. It only goes up and restarts with the daemon. Every change of a + device's published state (values, `pending`, `unread`, `available`) gets a new `rev`. +2. All requests for one device go through that device's queue and are answered in request + order, with these exceptions: `busy`, `unknown_device` and `unavailable` are answered at + once, and a stop (an action the model marks as a stop; on the Tiny 2 Lite `gimbal.stop` and + `zoom.stop`) is taken before every queued request and answered when the camera accepted + it. Requests for different devices may be answered in any order. Requests may be + pipelined. +3. For a `set` or `do` that changes state, the daemon sends the state event to every + subscribed connection that receives it (rule 8), the requester included, **before** the + reply. The reply's `rev` is the rev of the first state that reflects the request: that + state shows the written value, or lists the path in `pending`. Several requests may share + one state and one rev. If nothing changed, the reply carries the current rev. +4. `pending` is part of every state: a map from leaf path to the value written. A path is in + `pending` while the camera accepted a write but its readback does not show the written + value yet. It leaves `pending` when a read of its group shows the written value (after + rounding to the setting's precision), or when the setting's `settle_ms` has passed at a + read of its group; then the camera's value stands, whatever it is. A new write of the + same path replaces its pending value and restarts its bound. `pending` is emptied when + the device becomes unavailable. Clients show their own value from sending a request + until its reply arrives, and the state (its `pending` values included) after that. They + run no timers of their own. +5. `set`: the whole patch is validated first, against the description and against the + current state with the patch applied. If any field fails, nothing is sent. The worker + then applies the leaves in the order the model defines. On a camera failure midway it + stops, re-reads, publishes the real state and answers `camera` with `applied` and + `failed`. There is no rollback: the camera has no transactions. The same holds for `do`: + all parameters are validated before the first frame is sent, and a failure after a frame + went out is answered the same way. +6. Motion actions (on the Tiny 2 Lite `gimbal.move`, `gimbal.turn`, `zoom.move`, + `preset.recall`, `view.go`, `zone.go_return`, `hand.go_return`, `power_on.go`) reply + when the camera accepted the command. The state shows measured values (pose, zoom + factor), so it moves through later events. +7. Additive changes (fields, actions, models, enum values, live topics, setting types, + units) keep `protocol: 2`. A breaking change raises the number. Clients ignore unknown + fields and unknown event types, show unknown enum values as raw text, show settings of an + unknown type read-only as text, and treat an unknown error code as `internal`. +8. **Live topics.** The description names a live topic for each volatile leaf (section 4.2). + A state change that touches only volatile leaves, and that no request caused, is sent + only to connections whose live list holds one of their topics. Every other connection + receives the new values with its next event. A change caused by a `set` or `do` is sent + to every subscribed connection. A topic name no device uses has no effect. +9. **Queue handling.** + - The worker takes the requests queued for its device as one batch. It runs them in + order, then re-reads once the union of the groups they touch (section 8.2), publishes + one state, and answers them in order. A camera failure ends the batch; the requests + after it stay queued. + - A `get` never reads the camera. It is answered, in its turn, from the published state: + the paths asked for, plus `available`, `pending` and `unread` in full. + - A `set` whose patch has exactly one leaf is answered `ok` with the current rev, without + being sent, when a later queued `set` from the same connection has the same single leaf. + - A request whose connection closed before the worker started it is dropped. A started + request runs to the end. + - Every device request (`get`, `set`, `do`) is answered within 15 s of its arrival + (`REQUEST_DEADLINE`). A request not started by then is dropped and answered `camera`. + A started request checks the deadline before each frame and, once it has passed, + stops as on a camera failure. + +**Reconnect (client behaviour):** reconnect with a backoff from 0.5 s, doubling up to 5 s. +Socket activation starts the daemon. Requests die with their connection. After any +reconnect a client drops every pending request and every own value, sends `hello` and +rebuilds from its reply. Revs from two connections are never compared. + +**`hello` timing:** the daemon answers `hello` only after its first complete enumeration +(section 5.2). The reply and the start of event delivery are atomic: no state published +after the reply's snapshot is missing from the events that follow. + +### 4.2 Devices and descriptions + +``` +Device = { id, name, model, support, serial, firmware, video_node, available, + replaced_by?, usb, settings{ → Setting}, + actions{ → Action}, limits{ → max items} } + +usb = { vendor, product, product_name, bcd_device, path, serial_present, + interfaces[…], extension_units[{unit, guid, controls}…] } + +Setting = { type, label, readonly, nullable, readback, asleep, no_effect, + values[], writable[] (enum) + min, max, step, unit, precision (number, range, pose, point, box) + kind, optical_max (zoom factor only; kind digital|optical|hybrid) + max_len, ascii (string) + item{ → Setting} (list; read-only) + optional (action parameters only) + requires[{path, equals}…], default, live, settle_ms, warning } + +Action = { label, params{ → Setting}, result{ → Setting}?, asleep, + requires[…]?, warning? } +``` + +Device fields: + +- `id`: `sn:` when the model's identity source gave a valid serial, else + `port:` (section 5.5). A `port:` id is never stored. +- `name`: the model's display name plus the last four characters of the serial + (`OBSBOT Tiny 2 Lite 0001`); without a serial, the display name plus the USB path; for an + unsupported device, the USB product name plus the USB path. +- `model`: the model key (`tiny2lite`, `fake-a`, `fake-b`); `unknown` for an unsupported + device. +- `support`: `tested`, `untested` or `unsupported` (section 8.6). It is device-level only. + Per-field evidence lives in the model's ledger, not in the protocol. +- `serial`, `firmware`: as the camera reported them, or `null`. +- `video_node`: `/dev/videoN` of the device's capture node, or `null`. +- `usb`: read from sysfs at enumeration, never from the camera. `serial_present` says + whether the USB descriptor has a serial string; the string itself is never in `usb`. + `devices --report` prints this object (section 6.2). +- `limits`: the maximum item count of a list setting, where the camera has one + (`{"presets":3}`). A list without an entry has no known maximum. + +Setting and Action fields: + +- `type`: `bool`, `enum`, `number`, `range` (`{min,max}`), `pose` (`{pan,tilt,zoom}`), + `point` (`{x,y}`), `box` (`{left,top,right,bottom}`), `string`, `list`. Shapes are always + objects. For `range`, `pose`, `point` and `box`, `min` and `max` are objects of the same + shape and bound each component. +- `label`: English, for CLI output. +- `values[]` are all values a state may show; `writable[]` the ones a `set` accepts. +- `unit` is one of `deg`, `deg/s`, `factor`, `us`, `K`, `percent`, `s`, `fraction`, `fps`, + or absent for a raw camera value. New units are additive. +- `readback`: `false` means the camera accepts the setting but cannot report it. +- `asleep`: `accepted`, `ignored` or `unknown` (section 5.7). +- `no_effect`: the camera accepts and reports the value, and it has no observable effect on + this model. +- `requires`: conditions on other paths. The check uses the current state with the patch + applied, so `{"exposure":{"mode":"manual","time":20000}}` is accepted while the exposure + is automatic, and `exposure.time` alone is refused with `conflict`. +- `default`: the camera's own default as it reports it (UVC). Absent when the camera + reports none or when the reported value lies outside the setting's range. +- `live`: the live topic of a volatile leaf: a value that changes without a request (pose, + target box, values the camera adjusts itself). +- `settle_ms`: the longest time the camera's readback may lag a write of this leaf + (rule 4). It is a measured property of the model. +- `warning`: `restart` (the camera restarts after this write or action) or `destructive` + (data on the camera is lost). An action with `warning: destructive` requires the + parameter `confirm: true`; without it the daemon answers `invalid`. Generic UIs ask + before they send either. + +Rules: + +- **Leaves.** Settings are described per leaf path (`zone.area.pan`, `led.brightness`). Only + leaves are writable. +- **One meaning per path.** A path means the same function, unit and frame on every model. + Models differ in which paths they have and in `values[]`, `writable[]` and bounds. A new + function gets a new path or a new enum value. +- **Absent vs null.** A field the model does not have is absent from state and + description. `null` means one of: the camera reports "not set" (only where the description + says `nullable`); the leaf was never read (it is in `unread`); or a `readback:false` leaf + that was not written since the device last became available. +- **`unread`** is part of every state: the leaf paths whose last read failed. They keep + their last value. Their group is read again at its next tier tick while the camera is + awake. +- **Undecodable values.** A camera value the model cannot decode is published as the + string `raw:` (n = the raw number) and logged once per device and value. It is never + turned into `null` or into a neighbouring value. +- **`readback:false` lifetime.** The state shows the value written since the device last + became available, `null` before the first write, and `null` again after unplug, + re-enumeration and `factory_reset`. Nothing is re-sent. Across sleep the value is kept + until camera item CAM-6 shows otherwise. +- **Units and frames.** Angles in degrees; pan positive = the camera turns to its left; tilt + positive = up; 0/0 = straight ahead. Picture coordinates 0..1 from the top-left corner. + Zoom as a factor (1.0 = widest). Gimbal speeds in °/s. Durations in seconds (precision + 0.1). Exposure time in microseconds. Colour temperature in K. Percentages 0–100. +- **Precision.** Each number has a precision in its description (angles 0.1, zoom 0.01, + fractions 0.01). Values are rounded to it where they are created (decode and echo), so + state and `--json` never show binary float noise. +- **Identity and serial exposure.** The full id and serial travel on the socket and in the + CLI's `--json` and `watch` output. Human CLI output, logs and the journal show `…` plus the + last four characters (`sn:EXAMPLE0000001` → `…0001`), through one formatter. No log line + prints an identity answer. +- **Lists.** Items of `presets` and `views` carry the camera's own numbers: preset slots + start at 0, view ids are the camera's ids. + +### 4.3 State model of the Tiny 2 Lite + +This is the reference model. `descriptions/tiny2lite.json` is its normative form, +generated from the model module. Another model has other paths and values under the rules +of 4.2. ✎ = writable leaf. Ranges, units and asleep behaviour are in the description; the +evidence per field is in `docs/field-ledger.md`. Fields marked U-n are not backed today; +section 9.3 gives their fate. + +``` +available daemon: enumeration only +pending { : }, unread […] daemon +asleep read-only +on_air { active, apps[] } read-only +gimbal { pan, tilt, invert✎ } pan, tilt: live topic "pose" +zoom { factor✎, field_of_view✎ } +tracking { mode✎, profile✎, speed✎ (U-1), target { detected, box } } +framing { auto_zoom✎, size✎, zoom_speed✎, offset { x✎, y✎ }, part✎ } (U-3, U-4) +gestures { target_lock✎, zoom✎, zoom_ratio✎, dynamic_zoom✎, direction_change✎ } +zone { enabled✎, follow✎, area { pan✎, tilt✎ }, return } +hand { side✎, area { pan✎, tilt✎ }, axes { pan✎, tilt✎ } (U-5), + return_after✎ (U-6), move_gimbal✎, return } +presets [ { slot, name, pose } ] +views [ { id, name, pose } ] (U-14) +exposure { mode✎, region✎, time✎, gain✎, compensation✎, hdr✎, anti_flicker✎ } +image { brightness✎, contrast✎, saturation✎, hue✎, sharpness✎, + white_balance { auto✎, temperature✎ }, mirror✎, frame_rate } +focus { mode✎ (U-15), region✎, position✎ (U-22) } +audio { enabled✎, volume✎ (U-17), noise_reduction✎, auto_gain✎, in_sleep✎ } +led { brightness✎, blink✎ } +sleep { auto { enabled✎, seconds✎ }, background✎, background_mirror✎, + wake_zoom✎ (U-21) } +power_on { mode✎, start { source, pose, mode✎ (U-18) }, settings { … } (U-19) } +``` + +Per group: + +- `on_air`: `active` and `apps` are taken together in one sample. `active` is true for any + reader of the video node, a client's own preview included; a client that wants "another + application" accounts for its own stream. `apps` holds process names, best effort. On + kernels up to 6.16 the probe briefly holds the stream's exclusive privilege, so another + application that starts its video at that moment can fail with "device busy"; 6.17 and + newer are not affected. +- `gimbal.invert` reverses the gimbal's pan direction. +- `zoom.factor`: 1.0–4.0, `kind: digital`. It follows `zoom.field_of_view`, the zoom + gestures and `preset.recall`. +- `zoom.field_of_view`: values `wide|medium|narrow|custom`, writable `wide|medium|narrow`. +- `tracking.mode`: `static|normal|upper_body|close_up|headless|lower_body|hand|group`. A mode + change rewrites `framing.*`: `upper_body` and `close_up` set `framing.auto_zoom` and + `framing.size`; `headless` and `lower_body` set `framing.part`. +- `tracking.profile`: `standard|headroom|sport` (`headroom` is U-2). The vendor app labels + this "tracking speed". +- `tracking.speed`: `lazy|slow|normal|fast|very_fast|auto`. +- `tracking.target`: `detected` (bool) and `box` (`{left,top,right,bottom}` as picture + fractions, `null` without a target; layout measured, #71). `box` has the live topic + `target`. +- `framing`: `size` `long_shot|medium_long_shot|medium_shot|medium_close_up|close_up|big_close_up`; + `zoom_speed` `slow|medium|fast`; `offset.x` and `offset.y` −0.16..0.16 (fraction); + `part` `normal|head_removal|lower_body`. +- `gestures.zoom_ratio`: 1.0–4.0, step and precision 0.1 (the readback has one decimal). + `gestures.direction_change` reverses the direction of the dynamic zoom gesture. +- `zone.area.pan` −150..150, `zone.area.tilt` −70..90 (degrees, ranges). `zone.return` is a + pose and never `null`: the camera has no "not set" for it. +- `hand.side`: `left|right`. `hand.area` defaults to pan −45..45, tilt −30..30. + `hand.return_after` in seconds. `hand.move_gimbal`: `readback: false`; the camera does not + 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. +- `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 + steps, and the state shows the value it took. `exposure.gain` 1–64. `exposure.time` and + `exposure.gain` have the live topic `auto`. `exposure.compensation`: the camera's raw + value 0–18, default 9 (U-20). `exposure.anti_flicker`: values `off|50hz|60hz|auto`, + writable `off|50hz|60hz` (the kernel driver offers no way to set `auto` on this camera), + no `default`. +- `image`: brightness, contrast, saturation, hue and sharpness are UVC controls with the + ranges and defaults the camera reports (constants of the model). The camera has no gamma + control. `white_balance.temperature` `requires` `white_balance.auto=false` and has the live + topic `auto`. `frame_rate` is read-only (fps). +- `focus.mode`: `continuous|single|manual`. `focus.region`: `face|global`. + `focus.position` 0–100, `requires` `focus.mode=manual`, `no_effect` on this model (#99). +- `audio.enabled`: `warning: restart`; the camera restarts on every write, both values + (about 18 s, measured). Its readback is the presence of the camera's USB audio interface + in sysfs (U-23). `audio.volume` 0–100. `audio.noise_reduction`: `off|weak|medium|strong`. +- `led.brightness` 0–3. `led.blink`: `readback: false`. +- `sleep.auto.enabled` and `sleep.auto.seconds` are one word on the camera. The camera keeps + the seconds while auto-sleep is off; writing `seconds` alone does not switch it on. Range + 1–32767 s: the camera cannot store "off" together with 0 s, so 0 is not offered. + `sleep.background`: values `black|image|own_image`, writable `black|image` (`own_image` + shows after an upload, which 1.0 does not offer). `sleep.wake_zoom`: 1.0–4.0, `no_effect`. +- `power_on.mode`: the tracking mode the camera starts in at power-on (0.13 `wake mode`), all + eight modes (measured, #102). +- `power_on.start`: the camera's initial state (0.13 `startup`). `source` is `own` or + `preset_`, read-only. `pose` is read-only and set through actions. `mode` is the + tracking mode of the initial state: values `static|normal|upper_body|close_up|headless|` + `lower_body|hand|group|unset`, writable `static|normal|upper_body|headless|group|unset` + (all but `normal` and `unset` are U-18). `unset` is the camera's "not set" and differs + from `static`. +- `power_on.settings`: read-only leaves of the same block (`tracking_speed`, `autofocus`, + `auto_exposure`, `exposure_compensation`, `white_balance_mode`, `white_balance_temperature`, + `contrast`, `saturation`, `sharpness`, `hue`) as raw camera values; `null` where the block + says "not set" (U-19). Changing the pose or the mode rewrites the block and keeps these + values. + +Live topics of the Tiny 2 Lite: `pose` (`gimbal.pan`, `gimbal.tilt`), `target` +(`tracking.target.box`), `auto` (`exposure.time`, `exposure.gain`, +`image.white_balance.temperature`). + +Removed from the 0.13.2 output (no settable function is lost; section 9): + +- `capabilities` and `limits`: the description replaces them. +- `status` (the raw camera block) and `state` (the daemon's remembered values). +- `model_info`, `digital_zoom_percent`, `sleep_images`, and the raw V4L2 fields. +- `device` (the device path): now `video_node`. `streaming`: now `on_air`. + +Merged duplicates: `focus.auto` (UVC continuous autofocus) is the same camera bit as +`focus.mode=continuous`. `audio.pickup` is `audio.volume` quantised (below 50 near, 50–84 +standard, from 85 far); UIs may offer those three as volume presets. + +Not offered in 1.0: flipping the image, resetting the start pose, resetting the zone area. +Their candidate commands are neither recorded nor sent by 0.13.2 (CAM-11, CAM-12). + +### 4.4 Actions of the Tiny 2 Lite + +``` +gimbal.move {pan?, tilt?, speed? (U-9)} gimbal.stop (U-10) +gimbal.turn {pan_speed?, tilt_speed?, keep?} +zoom.move {factor, speed (U-12)} zoom.stop (U-11) +preset.save {slot, name?, pose?} preset.recall {slot} +preset.rename {slot, name} preset.delete {slot} +view.save {name, pose?} → {id} view.go {id} (U-13) +view.rename {id, name} view.delete {id} +zone.return_here zone.go_return +hand.return_here hand.go_return +hand.return_clear hand.area_reset (U-7) +focus.point {x, y} (U-16) +power_on.set_pose {pose} power_on.use_current +power_on.use_preset {slot} power_on.go (U-24) +sleep wake +factory_reset {confirm} +``` + +- `gimbal.move`: at least one of `pan` and `tilt`; both missing is `invalid`. A missing axis + keeps its position (the camera is told to leave it). `speed` in °/s makes the move take as + long as that speed needs. +- `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. +- `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). + `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`. +- `power_on.set_pose`, `power_on.use_current` and `power_on.use_preset` rewrite the initial + state and keep its tracking mode and `power_on.settings`. +- `sleep` and `wake` reply when the camera accepted the command. The state shows `asleep` + as the camera reports it; clients wait for that state, never for their own value. +- `factory_reset`: `warning: destructive`, needs `confirm:true`. It replies when the camera + accepted it. The restart that follows is ordinary hotplug (section 5.3). It deletes the + presets. +- Every action states its `asleep` behaviour in the description; section 8.8 lists the + Tiny 2 Lite's. + +Not carried over from 0.13: the hidden `gimbal center` (it moved the gimbal only toward the +left and downward in tests). Revision 1's `gimbal.step`, `image.reset` and `device.rename` +are dropped (section 15). + +## 5. Daemon (obsbot4linuxd) + +The daemon knows devices, groups, tiers, leaves, actions and live topics. It knows no +command id, no offset and no selector of any model: everything model-specific comes through +`trait Camera` (section 8.2). + +### 5.1 Threads + +Synchronous threads, no async runtime: + +- an acceptor; +- per connection one reader thread, which parses requests and routes them, and one writer, + which drains the connection's outbound queue (64 lines; when it is full, the connection is + closed and a log line names it); +- a registry thread, which runs the enumeration (5.3); +- one worker per supported device: the only code that touches that device. + +A device table (id, description, rev, last state, pending, unread) sits outside the +workers under one lock. `hello` takes that lock to build its snapshot and to register the +connection for events. + +### 5.2 Start-up + +1. Take the single-instance lock (5.9). Inherit the socket from systemd, or bind it. +2. Run one enumeration pass (5.3). +3. For every supported device: start its worker, which runs the first identity attempt and + a first read of every group. +4. Only then answer `hello`. A device found later enters by a `devices` event after its + first read attempt has finished. The leaves of a group whose first read failed are + `null` and listed in `unread`. + +### 5.3 Enumeration and hotplug + +- Once a second the registry thread lists `/sys/class/video4linux`. For each node it + resolves the USB device the node belongs to and reads, from sysfs only: vendor id, product + id, product string, whether a serial string exists, bcdDevice, the interface classes, the + extension units from the USB descriptors, and the node index. It never opens a video node + during the scan. +- A USB device whose vendor id is not in the registry's vendor table (section 8.1) is + ignored and not listed. +- A device with a known vendor id is matched by (vendor id, product id) against the + registry. A match becomes a supported device; its worker opens the node with index 0. A + miss becomes an unsupported device (5.4). The product string is never used for matching: + the camera can rename itself. +- `available` changes only here: true when a device appears, false when its sysfs entry is + gone. A failed read never sets `available:false` and never drops the identity; it shows in + `unread` or as a `camera` error. A read that fails because the node is gone is reported at + once, without waiting for the answer timeout. +- On unplug an `sn:` entry stays listed with `available:false` while the daemon runs; a + `port:` entry is removed. The re-plugged device takes its old `sn:` entry once its + identity is read (5.5). +- A camera that re-enumerates (after `factory_reset`, after an `audio.enabled` write, in + firmware-update mode) is ordinary hotplug: the old node goes, a new one comes. A camera in + firmware-update mode has no video node, so the daemon treats it as unplugged and sends it + nothing. + +### 5.4 Unsupported devices + +A device with a known vendor id and an unregistered product id gets an entry with +`support: unsupported`, `model: unknown`, id `port:`, `serial: null`, +`firmware: null`, its `usb` facts, its `video_node`, `available`, and empty `settings`, +`actions` and `limits`. Its state is `{"available":…,"pending":{},"unread":[]}`. The daemon +sends it nothing: no identity query, no status read, no on-air probe, no extension-unit +request of any kind. It never opens its video node. One log line per plug-in names its USB +ids. `set` and `do` on it are answered `unsupported`. Section 8.4 gives the whole +walk-through. + +### 5.5 Identity + +- The identity source is a property of the model (section 8.2): the USB serial string when + the model has one, else the model's identity read, else none. +- The model's identity read is sent at plug-in, whether the camera is asleep or awake. At + most three attempts: at plug-in and with the next two reads of the 5 s tier. +- If no attempt gave a valid serial and the camera was asleep, one further series of three + attempts runs from its first wake: at the wake and with the next two reads of the 5 s + tier. After that the id stays as it is until unplug. The id can change at most once per + plug-in. +- A serial is valid only if it matches `[A-Za-z0-9]{8,32}`. Without a valid serial the id is + `port:` for this plug-in, with a log line. +- If an available device already holds the same `sn:` id, the newcomer gets a `port:` id and + a log line. +- Identity values (serial, firmware) are kept only once the camera answered them. +- **Hand-over.** When a `port:` id resolves to `sn:`, the device takes that id. If an + unavailable entry with that id exists, the device takes that entry. The `devices` event + lists the old entry once more with `available:false` and `replaced_by:""`, then + the old entry is gone. Requests to the old id are answered `unknown_device`. Clients move + their selection. + +### 5.6 Worker and write path + +- 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 → + 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 + or enters `pending` with it (4.1 rule 4). While a leaf is pending, its group is also read + at every tick of the 5 s tier. A `readback:false` leaf takes the written value once the + camera accepted the write. +- **Stops.** A stop action jumps the queue (4.1 rule 2). A group read in progress ends at + the next frame boundary; its leaves are then listed in `unread`, and the group is read + again at its next tick. +- **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. + +### 5.7 Polling and asleep + +- **Poll plan.** The model names its groups. Each group has a tier: 1 s, 5 s, 60 s, or + `touched` (read at plug-in and after a request that touches it, never periodically). + Each group says whether it can be read while asleep. A group may name a live topic and a + faster tier used while at least one connection subscribes that topic. +- Intervals count from the end of the previous read of that tier, so a slow read never + starves the 1 s tier. +- Polls run group by group. Queued requests are served between groups, never inside one. + A group read stops at its first unanswered query; the group's leaves are listed in + `unread`, and the other groups go ahead. +- An unchanged read produces no new rev and no event. +- **Asleep.** If the description has the leaf `asleep`, the worker uses its value. While it + is true, only the groups readable while asleep are read. A setting or action with + `asleep: ignored` is refused with `asleep`. One with `accepted` or `unknown` is written; if + its group cannot be read while asleep, the path stays in `pending` until the first read + after wake, and its `settle_ms` counts from that read. + +### 5.8 Timeouts + +| Name | Value | When it runs out | +| ---------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `ANSWER_POLL` | 20 reads, 50 ms apart (about 1 s) per query | the query is unanswered; its group's leaves go to `unread`; a request gets `camera` | +| `FRAME_GAP` | 100 ms between two frames of one command, where a model needs it | – | +| `NODE_LOCK_WAIT` | 5 s for the per-operation lock on the video node | a request gets `camera`; a poll skips the cycle | +| `REQUEST_DEADLINE` | 15 s from a request's arrival | 4.1 rule 9 | +| `CLIENT_REPLY_TIMEOUT` | 30 s, in the client library | the client reports a lost daemon (CLI exit 4) | +| `ACCEPT_BACKOFF` | 100 ms after a failed accept | – | + +The per-operation lock on the video node is kept from 0.13: a second process that talks to +the same camera (a second user's daemon, the firmware updater) takes turns with the daemon +on single frames. One daemon per camera is assumed. The kernel's own UVC control timeout +bounds each control transfer; its value is camera item CAM-29. + +### 5.9 Single instance, shutdown, failures + +- **Single instance.** Every daemon, socket-activated or not, takes an exclusive `flock` on + `.lock` first. If the lock is held, it exits non-zero with a message that + names the lock file. A daemon that binds the socket itself first checks whether something + already accepts connections on that path and exits with a message if so; then it removes a + stale socket file and binds under umask 077. +- **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. +- **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. + +### 5.10 Operation + +- The unit names stay `obsbot4linux.socket` and `obsbot4linux.service`; `ExecStart` runs + `obsbot4linuxd`. The socket path does not change. +- The daemon starts only through the systemd socket. Packages do not enable the socket (as + in 0.13). No CLI command, `watch` included, starts a daemon. +- A client that cannot connect (no socket file, connection refused) exits 4 and prints + `systemctl --user enable --now obsbot4linux.socket`. +- A reply to `hello` that is not a protocol-2 object (0.13 answers with `ok:false` and a + string `error`) means an older daemon is running. The client says so ("an obsbot4linux + daemon older than 1.0 is running: restart it or log out") and exits 4. Clients never stop + or restart a daemon. +- Hardening in 1.0: `NoNewPrivileges=yes` in the service unit. Other lines come after 1.0. +- A firmware update requires `obsbot4linux.socket` and `obsbot4linux.service` stopped until + M5 defines the lease. +- The daemon reads and writes no file except its socket and its lock file. + +### 5.11 Logs + +Logs go to stderr and so to the journal. Serials and `sn:` ids are shortened by the one +formatter of 4.2. No log line prints an identity answer. An unsupported device gets one log +line per plug-in. + +### 5.12 Fake mode + +- `obsbot4linuxd --fake [,…]` runs the daemon with fake devices instead of the + sysfs scan. It never touches a real device. +- The fake models are second implementations of `trait Camera`, in memory, in + `obsbot-device/models/fake`: + - `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 `presets`), a setting and an action under a group no client knows, another + preset count, `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. It enters + through the same registry lookup as a real device and so becomes an unsupported entry. +- Fake devices have synthetic serials (`sn:FAKEA0000001`) and synthetic USB facts. +- **Test control** goes through a second socket, `.test`, which exists only in + fake mode. One JSON object per line: `{"plug":""}`, `{"unplug":""}`, + `{"sleep":""}`, `{"wake":""}`, `{"fail_next":""}` (the next camera operation + on that device fails, an identity read included). It is not part of protocol 2 and not in + any schema or description. +- CI runs `--fake fake-a,fake-b,unknown` in every daemon, client and CLI test that needs a + daemon (section 11). + +## 6. Client and CLI + +### 6.1 Client library (`obsbot-proto`, module `client`) + +```rust +let mut c = Client::connect(Hello::new("my-tool 1.0"))?; // subscribe:false +let devices: &[Device] = c.devices(); +let (rev, state) = c.get(&id, Some(&["zoom.factor"]))?; +let rev = c.set(&id, Patch::new().leaf("zoom.factor", 1.5))?; +let (rev, result) = c.act(&id, "preset.recall", json!({"slot": 1}))?; + +let (tx, rx) = Client::subscribe(Hello::new("widget 1.0").live(&["pose"]))?; +let req = tx.set(&id, Patch::new().leaf("led.brightness", 2))?; // returns the request id +for msg in rx { match msg? { Incoming::Reply { id, .. } => …, Incoming::Event(e) => … } } +``` + +- State, patches and action parameters are path → JSON value. The library offers path + helpers (`state.at("zoom.factor")`, `Patch::leaf`) and no per-setting methods. It may + validate a patch against the description with the same validator the daemon uses; the + daemon stays authoritative. +- `Client::connect` blocks per request. `Client::subscribe` returns a sender and a receiver; + a reader thread delivers replies (with their request ids) and events in arrival order. + The caller keeps its own device list from `hello` and the `devices` events. +- Errors are typed (`ErrorCode`, with an unknown-code variant). There is no built-in retry: + on disconnect the receiver ends with an error, and the caller decides whether to + reconnect (4.1). +- Public enums are `#[non_exhaustive]`, so additive protocol changes do not break Rust + callers. + +### 6.2 CLI (obsbot4linux) + +The CLI is generic. It maps 1:1 onto protocol 2, so a new model or setting needs no CLI +change. There are no per-feature subcommands and no shortcuts. + +``` +obsbot4linux status state of one device, grouped +obsbot4linux get … leaves or groups +obsbot4linux set = […] one patch +obsbot4linux do [= …] +obsbot4linux describe [] the device description +obsbot4linux devices [--report] device list; --report: USB facts +obsbot4linux watch [--live …] hello reply and events as JSON lines + --device needed only with more than one usable device + --json print the protocol objects +``` + +- **Device selection.** `--device` takes the full id, a serial suffix of at least four + characters that matches exactly one device, or a model key that matches exactly one + device. Without `--device` the CLI uses the only usable device (support `tested` or + `untested`, available). With none it exits 3; with several it exits 2 and prints the list. + Unsupported devices never count as usable. +- **Unsupported devices.** `devices`, `devices --report`, `describe` and `watch` work for + them. `status`, `get`, `set` and `do` exit 3 and print: " (USB :) + is not supported by obsbot4linux ; `obsbot4linux devices --report` prints what + an issue report needs". +- **Value syntax**, one per setting type: bool `on|off|true|false`; enum by name + (`upper_body`, also `upper-body`); number `1.5`; range `min,max` + (`zone.area.pan=-20,20`); pose `pan,tilt,zoom`; point `x,y`; string as given (quoted for + the shell). Only leaves are set. Action parameters use the same syntax + (`do view.save name=Desk pose=0,5,1.2`). A `key=value` argument never starts with `-`; + free text after `=` may. +- **Output.** Human output is grouped, with units, serials shortened (4.2), and pending + values marked `(pending)`. `--json` prints exactly the protocol objects: the `ok` payload + of the reply, or for `status` the device entry and its state. With `--json`, an error + (usage errors included) prints `{"error":{"code","message"}}` on stdout; help and version + stay plain text. +- **`devices --report`** prints, per device, the `usb` object of 4.2 together with + `model`, `support` and `firmware`. It never prints the serial value: only + `serial_present`. +- **`watch`** sends `hello` with `subscribe:true` and the given live topics, prints the + reply as its first line and then every event, one JSON object per line, as received. It + reads nothing from stdin. It exits 0 on SIGINT or SIGTERM and 4 when the connection + ends. +- **Exit codes:** 0 ok; 1 `internal`; 2 invalid input (`invalid`, `out_of_range`, + `unsupported`, `unknown_device`, a usage error, several usable devices without + `--device`); 3 device not usable (`conflict`, `unavailable`, `asleep`, `busy`, `camera`, + no usable device, an unsupported device addressed); 4 daemon unreachable, wrong protocol, + an older daemon, or no reply within `CLIENT_REPLY_TIMEOUT`. +- **One-shot commands** send `hello` with `subscribe:false` and read with `get`. The CLI + 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. +- **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 + (`descriptions/*.json` of real models only, never the fakes), committed under + `packaging/completions/`, and a CLI-crate test fails when they are stale. Tab never + contacts the daemon, and `--device` values are not completed. A new model regenerates the + description file and the completions in its PR; the CLI code does not change. How the + scripts are generated is a choice of track C. +- **Relay fallback.** If the widget cannot open the Unix socket from its shell (track D's + first check, section 10.3), the CLI gets `relay`: it connects, copies stdin lines to the + socket and socket lines to stdout unchanged, and sends nothing of its own. Owner: track + C. It is additive. It is not built unless that check fails. + +## 7. Multiple cameras + +The daemon handles any number of cameras. Mixed models are not a special case: each device +brings its own description. The CLI needs `--device` only with more than one usable device. +The widget and the desktop app have a device picker. CI runs every daemon, client and CLI +test that needs a daemon with `fake-a`, `fake-b` and `unknown` together. + +## 8. Models + +### 8.1 Registry + +- The registry key is the USB (vendor id, product id) pair, read from sysfs before any I/O. + A model lists every pair it answers to. A test fails if a pair belongs to two models. The + product string, the vendor app's model number and the firmware's product code are notes + of the model, never keys. +- The **vendor table** lists the OBSBOT vendor ids that make a device visible to the + daemon. It holds only vendor ids read from a unit in hand: today `0x3564` (measured on the + Tiny 2 Lite). A further vendor id is added in a registry PR when a unit with it is at + hand. A device whose vendor id is not in the table is not listed at all. +- Registered today: `tiny2lite`, `3564:fef9` (measured). Nothing else. + +### 8.2 What a model provides (`trait Camera`) + +A model is one module in `obsbot-device/models/` plus a registry entry (key, display name, +USB pairs, support level, a constructor that takes a `Transport`). Its type implements: + +```rust +pub trait Camera: Send { + fn description(&self) -> &Description; // settings, actions, limits; settle_ms + fn identity(&mut self) -> Result; // the model's identity source + fn poll_plan(&self) -> &[Group]; // name, tier, readable asleep, live topic + fn read(&mut self, group: &str) -> Result; // leaf path → value + fn order(&self, leaves: &[&str]) -> Vec; // safe apply order of a patch + fn apply(&mut self, leaf: &str, value: &Value) -> Result<()>; + fn act(&mut self, action: &str, params: &Value) -> Result>; + fn touched(&self, target: Target) -> Vec; // groups to re-read after a leaf or action + fn stops(&self) -> &[&str]; // actions that jump the queue + fn continuing(&self, action: &str) -> Option<&str>; // the stop of a continuing action + fn send_table(&self) -> &SendTable; // section 8.3 +} +``` + +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 where its captures show the same +layout, its own codec inside its module otherwise. A codec moves into `family` only when a +second model uses it. + +### 8.3 What is sent + +- Each model declares its **send table**: every extension-unit frame it may send (selector; + for framed commands the receiver and command id; for short commands the command) and + every UVC control it may write. Each entry carries its evidence: a capture key or a + measurement. For the Tiny 2 Lite, entries that only 0.13.2 sends (the U-rows of 9.3) are + marked as such until camera session CS-1 either measures them or removes them. +- The transport wrapper checks every outgoing frame against the active model's table in one + place. Anything else is refused with `internal`, logged and never sent. +- Independently of the table, the wrapper refuses two whole classes: every extension-unit + selector outside the set the family uses (commands, short commands, query answers, + status block, preset list and entry); and, on the receiver that answers identity queries + (`0x0d` on the Tiny 2 Lite), every frame that is not one of the model's declared identity + queries. +- 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. +- **No probing.** Capabilities come from the model's code, never from trying ids. The + extension unit is found by its GUID in the USB descriptor (read from sysfs), never by a + request. + +### 8.4 A camera with a known vendor id and an unknown product id (walk-through 2) + +1. The kernel enumerates the camera. Within a second the daemon's scan (5.3) finds a video + node whose USB device has a vendor id from the vendor table and a product id the registry + does not know. +2. The daemon creates the unsupported entry of 5.4: id `port:`, `model: unknown`, + `support: unsupported`, the USB facts from sysfs, empty settings and actions. It opens no + node and sends nothing. It logs one line with the USB ids. +3. Subscribed clients get a `devices` event with the new entry. The widget lists it as not + supported. +4. `obsbot4linux devices` lists it with "not supported" and exits 0. + `obsbot4linux devices --report` prints its USB facts (no serial value). +5. If it is the only device, `status`, `get`, `set` and `do` exit 3 with the message of + 6.2. If a supported camera is also connected, the CLI uses that camera without + `--device`, because unsupported devices never count as usable. +6. A `set` or `do` that reaches the daemon for it is answered `unsupported`. +7. On unplug its `port:` entry is removed and a `devices` event is sent. +8. The user opens an issue with the `devices --report` output. If the maintainers have the + device at hand, onboarding (8.7) starts at step 1. Otherwise nothing changes. + +CI runs this walk-through with the `unknown` fake (section 11). + +### 8.5 Evidence, ledgers, fixtures and public text + +- **Ledger.** Each model has a ledger: `docs/field-ledger.md` for the Tiny 2 Lite, + `docs/field-ledger-.md` for every further model. One row per leaf and action: + path, write (command and encoding), readback, evidence level with its key, asleep + behaviour, `readback`, `settle_ms`. Rows refer to spec sections, never to spec line + numbers. +- **Levels.** `captured` and `measured` are the only levels that let a field into a + released description. The model module records each field's level as a constant. A test + in stage W3 fails if any field of a model with `support: tested` has another level. +- **Fixtures** are single exchanges (request frame and answer), inline in a test or in a + small text table, each with its capture key. Recording files never enter the repository. + Identity answers and descriptors that hold a serial string are never fixtures; tests use + synthetic values. Fixtures come from the raw recordings, or from decoded files made by a + decoder that keeps zero-value writes. +- **Public text.** The repository is public. Spec, ledgers, code comments, fixtures and + issues may contain: paths, the command ids and encodings that are sent, ranges, units, a + model's own USB ids, evidence levels and opaque keys ("capture 11", "measured + 2026-10-31"). They never contain: paths or file names of private notes, names of + vendor-app methods or firmware symbols, whole command tables, descriptions of factory, + debug or upgrade commands, lists of ids that must not be sent, recordings or excerpts of + them, serial numbers or fragments of them, the address of a private server, or third + parties named as sources. The only statements about how knowledge was obtained are + "recorded from the vendor app" and "measured on the camera". + +### 8.6 Support levels + +- **`tested`**: the maintainers verified every row of the model's check on a unit (for the + Tiny 2 Lite: every parity row, section 9). The description holds only backed fields. The + README support table names the model and the firmware versions it was tested on. +- **`untested`**: built from an owner's captures (8.7 steps 1–6) without steps 7–8 by the + maintainers. Release notes list it under "Untested models", never as supported. Package + descriptions name tested models only. The description of an untested model has no action + with `warning: destructive`. +- **`unsupported`**: a known vendor id with an unregistered product id (5.4). +- The CLI prints the level next to every device that is not `tested`. The daemon logs it + once per plug-in. The widget and the desktop app show a marker for `untested` and + `unsupported` devices. + +### 8.7 Onboarding a model (walk-through 1) + +A model is added only with a unit in hand and with our own captures or an owner's captures. +Nothing is built for a model before that. Steps, in this order: + +0. **Visibility.** Plug the unit into a Linux host running obsbot4linux 1.0. + - If `obsbot4linux devices` lists it as unsupported, go to step 1. + - If it is not listed, its vendor id is not in the vendor table. Read `idVendor` and + `idProduct` of the unit from sysfs (`/sys/bus/usb/devices//`; `lsusb` shows + the same pair), add the vendor id to the table in a registry PR, and start again. + Nothing is sent to the camera in this step. +1. **Descriptor report.** Run `obsbot4linux devices --report --device port:` and + keep the output: USB ids, product name, bcdDevice, whether a serial string exists, + interface classes, extension units with GUID and control count, video node. If the unit + shows other product ids in other modes, record each. Open a tracking issue with the + report. +2. **Captures.** Record the USB traffic between the vendor app and the unit (for example + usbmon on a Linux host that passes the camera to a virtual machine running the vendor + app). Stop `obsbot4linux.socket` and `obsbot4linux.service` first, so that nothing else + talks to the camera. Keep a capture log: one line per step with time, control, old value + and new value. Record, in this order: + 1. the connect sequence: recording on, plug in, start the vendor app, 60 s idle (this + also records the idle poll); + 2. each setting the vendor app shows for this model, one value change per step, ending + at the original value; + 3. each action the vendor app offers; + 4. sleep and wake, and a plug-in while the camera sleeps. + + Nothing is sent from Linux in this step. + +3. **Private set and public fixtures.** The recordings and logs go to the owner's private + notes. Public fixtures are cut from them under 8.5: single exchanges, identity answers + replaced by synthetic values, each with its capture key. +4. **Model module** `models/` with its registry entry: + - USB pairs: every product id from step 1. Display name from the product name. Support + `untested` until step 8. + - Identity source: the USB serial string if `serial_present`, else the identity query + the vendor app sends in the connect sequence, else none (`port:` ids). + - Codec: the family codec if the captured frames match its layout and the extension-unit + GUID matches the family's; otherwise the model's own codec (8.2). + - Poll plan: groups and tiers taken from the captured idle poll, never more often than + the vendor app polls. + - Paths: an existing path where function, unit and frame are the same as on another + model (4.2); `values[]` and `writable[]` for other option sets; a new path or enum + value for a new function. This is additive and needs no client change (guarantee A). + - Each field gets its level in the model's ledger (8.5). A field with neither captured + nor measured evidence is not in the description. + - `asleep` is `unknown` for every field until step 7 measures it. + - `settle_ms`: until measured on this model, the largest `settle_ms` of any tested model + (today the Tiny 2 Lite's 3000 ms), marked "not measured on this model" in the ledger. +5. **Send table** (8.3), each entry with its capture key. +6. **Tests** without the camera: every encoder against the captured request bytes; every + decoder against captured answers; the status decoder against captured blocks with one + setting changed per block; the send-table test; the registry test. + `descriptions/.json` and the completions are regenerated. CI with the fakes stays + green. +7. **Check on the unit** by the maintainers: `scripts/check-model.sh --device ` (9.4). + Every passing row becomes `measured` with the date; `settle_ms` and `asleep` are set from + the measurements; failing fields leave the description. +8. **Release** as `tested`, with the firmware versions it was tested on, a README support + table row and a release-notes line. The CLI needs no change. The widget and the desktop + app show the settings they know at once; a setting new to them needs their own update + (guarantee A). + +A model built from an owner's captures that completed steps 1–6, but not 7–8 by the +maintainers, is released as `untested`. M4 is the first model that completes these steps. + +### 8.8 The Tiny 2 Lite model (`tiny2lite`) + +- USB `3564:fef9`; no USB serial string (measured: two units collide in `/dev/v4l/by-id`). + The extension unit is found by GUID; the descriptor recorded in capture 01 places it at + unit 2. +- **Identity:** the serial query `0x18c8` to receiver `0x0d` answers 14 ASCII characters. + It is recorded from the vendor app, which sends it at connect. A read from Linux, asleep and right + after plug-in, is camera item CAM-1. Firmware and model come from the queries the ledger + lists. +- **Live pose:** query `0x0043` to receiver 3, plus the zoom-factor query. The camera's + other pose query arms its switch to static after a zone return (#67) and is not in the + send table. +- **Poll plan.** 1 s: the status block (asleep, tracking mode and the other status values), + the on-air probe, and the target while the tracking mode is not `static`. 1 s while a + connection subscribes `pose`, else + 5 s: the live pose. 5 s: the settings readbacks (tracking, framing, gestures, zoom, + 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. +- **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`), and + `tracking.mode` before `framing.*`. The order 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. +- **Asleep:** `zoom.field_of_view` is `ignored` (measured); `power_on.go` is `ignored` + (0.13.2 behaviour; CAM-2 checks it). The + pose-taking actions of 4.4 are `ignored` until CAM-22. The readbacks of `audio.volume` + and `focus.*` do not answer while asleep (0.13.2 behaviour), so a write to them stays + pending until the first read after wake. Every other field is `unknown` until CAM-2. +- **`readback:false`:** `hand.move_gimbal`, `led.blink` (CAM-6). +- **`settle_ms`:** 3000 for every leaf. This is one model-wide bound taken from the worst + measured case (zone tracking follows 1–3 s after the write, #81; the target-lock status + about 1 s), labelled as such in the ledger. Per-leaf bounds are CAM-5. +- **Stops:** `gimbal.stop`, `zoom.stop`. **Continuing:** `gimbal.turn`, stopped by + `gimbal.stop`. +- **Support:** `untested` on `rebuild` until stage W3. The release candidate carries + `support: tested`, the claim that stage W4 then verifies on every parity row. If W4 fails + a row, the row is fixed or becomes a parity exception, and a new candidate is built. + +### 8.9 Tiny 3 and Tiny 3 Lite + +- They are two models: two registry entries, two modules. They may share functions where + captures show identical bytes. +- Nothing is built for them before a unit is at hand. Each follows 8.7 from step 0. +- Every property of either device is **unknown until captures**: USB ids, serial string, + extension unit, frame layout, status block, connect sequence, settings, values, asleep + behaviour. Section 14 lists the questions. +- Lite functions are not assumed to carry over. The vendor app treats models differently in + many functions (verified), so "common commands" cannot be assumed. One third-party report + links repeated identity queries to USB drops on a Tiny 3 (unverified); the cap on identity + attempts (5.5) holds for every model. +- Until a module exists, a unit of either model is an unsupported device (8.4), as long as + its vendor id is in the vendor table. + +## 9. Parity (1.0 acceptance) + +### 9.1 Parity table + +Every command and option of 0.13.2 (`src/cli.rs`) maps to a 1.0 command, or is a named +exception (9.2). 1.0 is released only when every row is verified on the real Tiny 2 Lite +(stage W4) or is an exception. U-n refers to 9.3, CAM-n to section 14. + +| 0.13.2 | 1.0 | Note | +| ----------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------ | +| `--json` (global) | `--json` | protocol objects; errors as `{"error":{…}}` | +| `--direct` (global) | – | PX-3 | +| `--help`, `--version` | `--help`, `--version` | | +| `daemon` | `obsbot4linuxd`, started by `obsbot4linux.socket` | | +| `watch` | `watch` | prints events; request forwarding is PX-4 | +| `live-pose on\|off` | `watch --live pose`; a client sends `live:["pose"]` | | +| `detect`, `detect --json` | `devices`, `devices --json` | PX-5 | +| `status`, `status --json` | `status`, `status --json` | new layout (4.3) | +| `sleep` | `do sleep` | | +| `wake` | `do wake` | replies on acceptance | +| `wake zoom F` | `set sleep.wake_zoom=F` | U-21 | +| `wake mode M` | `set power_on.mode=M` | | +| `tracking MODE` | `set tracking.mode=MODE` | | +| `speed standard\|sport` | `set tracking.profile=…` | | +| `speed headroom` | `set tracking.profile=headroom` | U-2 | +| `speed-mode MODE` | `set tracking.speed=MODE` | U-1 | +| `framing auto-zoom on\|off` | `set framing.auto_zoom=…` | U-3 | +| `framing size S` | `set framing.size=S` | U-3 | +| `framing zoom-speed S` | `set framing.zoom_speed=S` | U-3 | +| `framing offset [--x X] [--y Y]` | `set [framing.offset.x=X] [framing.offset.y=Y]` | U-4 | +| `framing part P` | `set framing.part=P` | U-3 | +| `hdr on\|off` | `set exposure.hdr=…` | | +| `exposure global\|face` | `set exposure.mode=auto exposure.region=…` | | +| `exposure manual` | `set exposure.mode=manual` | | +| `exposure manual --time T` | `set exposure.mode=manual exposure.time=` | time now in µs | +| `exposure manual --gain G` | `set exposure.mode=manual exposure.gain=G` | | +| `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` | U-11 | +| `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 stop` | `do gimbal.stop` | U-10 | +| `gimbal rate [--pan P] [--tilt T]` | `do gimbal.turn [pan_speed=P] [tilt_speed=T]` | the CLI sends `keep:true` | +| `gimbal center X Y` (hidden) | – | PX-1 | +| `fov wide\|medium\|narrow` | `set zoom.field_of_view=…` | | +| `led N` | `set led.brightness=N` | | +| `led blink on\|off` | `set led.blink=…` | `readback:false` | +| `mirror on\|off` | `set image.mirror=…` | | +| `invert on\|off` | `set gimbal.invert=…` | | +| `auto-sleep off` | `set sleep.auto.enabled=off` | | +| `auto-sleep N` (N ≥ 1) | `set sleep.auto.enabled=on sleep.auto.seconds=N` | | +| `auto-sleep 0` | – | PX-7 | +| `sleep-background black\|image` | `set sleep.background=…` | | +| `sleep-background-mirror on\|off` | `set sleep.background_mirror=…` | | +| `audio noise L` | `set audio.noise_reduction=L` | | +| `audio gain on\|off` | `set audio.auto_gain=…` | | +| `audio distance near\|standard\|far` | `set audio.volume=10\|50\|100` | the camera's pickup command sets exactly these volumes | +| `audio volume N` | `set audio.volume=N` | U-17 | +| `audio sleep-mic on\|off` | `set audio.in_sleep=…` | | +| `audio mic on\|off` | `set audio.enabled=…` | the camera restarts on both values; U-23 | +| `gesture set target-lock\|zoom\|dynamic-zoom\|direction-change on\|off` | `set gestures.=…` | names with `_` | +| `gesture zoom-ratio R` | `set gestures.zoom_ratio=R` | precision 0.1 | +| `hand right\|left` | `set hand.side=…` | | +| `hand return-here` | `do hand.return_here` | live pose | +| `hand return-go` | `do hand.go_return` | | +| `hand return-reset` | `do hand.return_clear` | | +| `hand area --pan MIN MAX --tilt MIN MAX` | `set hand.area.pan=MIN,MAX hand.area.tilt=MIN,MAX` | U-8 | +| `hand area-reset` | `do hand.area_reset` | U-7 | +| `hand pan\|tilt on\|off` | `set hand.axes.pan=…` / `set hand.axes.tilt=…` | U-5 | +| `hand return-after S` | `set hand.return_after=S` | U-6 | +| `hand gimbal on\|off` | `set hand.move_gimbal=…` | `readback:false` | +| `zone on\|off` | `set zone.enabled=…` | | +| `zone follow on\|off` | `set zone.follow=…` | | +| `zone area --pan MIN MAX --tilt MIN MAX` | `set zone.area.pan=MIN,MAX zone.area.tilt=MIN,MAX` | U-8 | +| `zone return-here` | `do zone.return_here` | live pose | +| `zone return-go` | `do zone.go_return` | | +| `zone view list` | `get views` | U-14 | +| `zone view save NAME` | `do view.save name=NAME` | live pose | +| `zone view save NAME --pan P --tilt T --zoom Z` | `do view.save name=NAME pose=P,T,Z` | | +| `zone view rename ID NAME` | `do view.rename id=ID name=NAME` | | +| `zone view delete ID` | `do view.delete id=ID` | | +| `zone view go ID` | `do view.go id=ID` | U-13 | +| `focus mode face\|global` | `set focus.region=…` | | +| `focus auto on` | `set focus.mode=continuous` | | +| `focus auto off` | `set focus.mode=manual`, or `single` if CAM-14 shows that | CAM-14 | +| `focus af continuous\|single\|manual` | `set focus.mode=…` | U-15 | +| `focus point X Y` | `do focus.point x=X y=Y` | U-16 | +| `focus set N` | `set focus.mode=manual focus.position=N` | U-22 | +| `startup set --pan P --tilt T --zoom Z` | `do power_on.set_pose pose=P,T,Z` | | +| `startup set --preset N` | `do power_on.use_preset slot=N` | | +| `startup set --current` | `do power_on.use_current` | live pose | +| `startup set --ai M\|none` | `set power_on.start.mode=M\|unset` | U-18 | +| `startup set --ai M` | the `do` of the pose option, then `set power_on.start.mode=M` | | +| `startup show` | `get power_on` | U-19 | +| `startup go` | `do power_on.go` | U-24 | +| `image brightness\|contrast\|saturation\|hue\|sharpness V` | `set image.=V` | | +| `image white_balance_auto 0\|1` | `set image.white_balance.auto=off\|on` | | +| `image white_balance_temperature V` | `set image.white_balance.auto=off image.white_balance.temperature=V` | `requires`; CAM-15 | +| `image gain V` | `set exposure.gain=V` | | +| `image power_line_frequency 0\|1\|2` | `set exposure.anti_flicker=off\|50hz\|60hz` | | +| `image backlight_compensation V` | `set exposure.compensation=V` | U-20 | +| `image auto_exposure 1\|3` | `set exposure.mode=manual\|auto` | V4L2 1 = manual, 3 = automatic | +| `image exposure_time V` | `set exposure.mode=manual exposure.time=` | µs | +| `image focus V` | `set focus.mode=manual focus.position=V` | U-22 | +| `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 --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` | | +| `preset delete N` | `do preset.delete slot=N` | CAM-18 | +| `preset recall N` | `do preset.recall slot=N` | | +| `factory-reset --yes` | `do factory_reset confirm=true` | `warning: destructive` | +| `factory-reset` without `--yes` | `do factory_reset` is refused with `invalid` | | + +### 9.2 Parity exceptions + +- **PX-1** `gimbal center X Y` (hidden in 0.13): not carried over. It moved the gimbal only + toward the left and downward in tests. +- **PX-2** `image gamma`: the camera has no gamma control (its recorded UVC descriptor does + not offer one); 0.13's command always failed. No function is lost. +- **PX-3** `--direct`: removed. The CLI never talks to the camera; socket activation starts + the daemon. +- **PX-4** request forwarding of `watch` from stdin: removed. The widget speaks protocol 2 + on the socket; `relay` is the fallback (6.2). +- **PX-5** `detect` without a daemon and with exit 1: replaced by `devices`, which needs the + daemon and exits 0 when it lists a device and 3 when the list is empty. A script that + needs a usable camera reads `support` from `devices --json`, or runs `status`, which exits + 3 without one. +- **PX-6** `zoom F --speed S` with any integer S: only `slow|medium|fast` (10, 30, 50), the + only speeds with a known meaning. +- **PX-7** `auto-sleep 0`: not offered. The camera cannot store "off" together with 0 s, and + "on with 0 s" was never recorded. +- **PX-U-n**: a row U-n of 9.3 whose check fails becomes the exception PX-U-n, with the + 0.13.2 rows named in its "Fail" column. + +### 9.3 Tiny 2 Lite fields without backing today + +These fields and values are 0.13.2 functions whose write and readback (or effect) are +neither captured nor measured. One rule holds for every row: the description written in +stage W0 contains the field or value (it is a 0.13.2 function); the check runs on the +camera in session CS-1 (stage A, camera item CAM-23); a field or value that passes is kept +with level `measured`; one that fails leaves the description by a contract-change PR before +the release candidate and becomes the named exception. The release candidate contains no +row that has not passed. No field stays on a guess, and none disappears silently. + +| U | Field or action | Backing today | Check in CS-1 | Fail: exception and 0.13.2 rows lost | +| ---- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| U-1 | `tracking.speed` | readback answers; no recorded write | set each value, read back | PX-U-1: `speed-mode` | +| U-2 | `tracking.profile=headroom` | `standard` and `sport` captured; `headroom` never tested | set, read back, watch the framing with a person in view | PX-U-2: `speed headroom` (value stays read-only) | +| U-3 | `framing.auto_zoom`, `.size`, `.zoom_speed`, `.part` | readbacks used by 0.13; no recorded write | set each value, read back | PX-U-3: the failing `framing` subcommand | +| U-4 | `framing.offset.x`, `.y` | readbacks used by 0.13; range −0.16..0.16 not confirmed | set both bounds and 0, read back | PX-U-4: `framing offset` | +| U-5 | `hand.axes.pan`, `.tilt` | no recording; effect not observed | set, read back; hand tracking with a person in view | PX-U-5: `hand pan`, `hand tilt` | +| U-6 | `hand.return_after` | no recorded write | set, read back | PX-U-6: `hand return-after` | +| U-7 | `hand.area_reset` | no recording | narrow the area, reset, read back the default area | PX-U-7: `hand area-reset` | +| U-8 | `hand.area`, `zone.area` with a new range wholly beyond the stored one | frame order unmeasured | write such ranges for pan and tilt, read back | the model sends the bounds in the order the check shows; re-checked | +| U-9 | `gimbal.move` with `speed` | no recording; tilt 86–90° and speeds 178–180 °/s unmeasured; one axis with speed untested | moves with speed to tilt 86, 88, 90; speed 178 and 180; pan only | range narrowed to the measured one; PX-U-9: `gimbal --speed` if no move with speed works | +| U-10 | `gimbal.stop` | no recording | start a turn, stop it, the pose stops changing | PX-U-10: `gimbal stop`, and `gimbal rate` with it (no turn without a stop) | +| U-11 | `zoom.stop` | no recording | slow zoom move, stop, the factor stops changing | PX-U-11: `zoom stop` | +| U-12 | `zoom.move` speeds `slow` (10), `fast` (50) | only 30 captured | a move at each speed, factor reached | the speed leaves `writable`; PX-U-12: `zoom --speed` with that speed | +| U-13 | `view.go` | never recorded or tested | save a view, move away, go, compare the pose | PX-U-13: `zone view go` | +| U-14 | `views` (the read) | the three reads not recorded; 0.13 lists views | list after save, rename, delete | PX-U-14: all `zone view` rows | +| U-15 | `focus.mode` values `single`, `manual` | only the continuous bit measured | set each value, read back, observe the focus | the value leaves `writable`; PX-U-15: `focus af single\|manual` | +| U-16 | `focus.point` | no recording | point at an object, observe the focus | PX-U-16: `focus point` | +| U-17 | `audio.volume` | readback used by 0.13; no recorded write | set 10, 50, 100, read back | PX-U-17: `audio volume`, `audio distance` | +| U-18 | `power_on.start.mode`: writing `static`, `upper_body`, `headless`, `group`; names of read values other than `normal` and `unset` | only `normal` and `unset` recorded from the vendor app | set each value, read the block, power-cycle, observe the start mode | the value leaves `writable`; an unconfirmed read value shows as `raw:`; PX-U-18: `startup set --ai` with that value | +| U-19 | `power_on.settings.*` | block partly decoded | change each in the vendor app's initial-state dialog (recorded), read the block | the leaf leaves the description; PX-U-19: that part of `startup show` | +| U-20 | `exposure.compensation` in EV | raw range 0–18 and default 9 captured (capture 12); 1/3 EV per step inferred from two clicks | compare the vendor app's displayed steps over the whole range | stays the raw value (no exception: the raw value is the 0.13 function) | +| U-21 | `sleep.wake_zoom` | readback answers; no effect observed (#102) | set, read back | PX-U-21: `wake zoom` | +| U-22 | `focus.position` | no effect observed (#99) | set in manual focus, read back | PX-U-22: `focus set`, `image focus` | +| U-23 | `audio.enabled` readback | the audio interface disappears and returns on toggle (measured); cold plug with the microphone off not measured | cold plug with the microphone off; compare the status-block candidate | readback from the status block if it follows; else `readback:false` | +| U-24 | `power_on.go` | the command 0.13 sends is not recorded | move away, run it, the camera goes to the start pose | PX-U-24: `startup go` | + +### 9.4 Check script + +`scripts/check-model.sh --device [--destructive]` checks one device against its own +description. It replaces 0.13's `check-readback.sh`. + +- It requires `--device`. It reads `describe --json` and builds its rows from it: every + writable enum value, both values of each bool, minimum and maximum of each number and + range. +- Per row: `set`, wait until a state with `rev` ≥ the reply's rev shows the path outside + `pending`, compare with the written value, restore. +- `readback:false` rows are checked as "accepted"; `no_effect` rows are checked by readback + only. The script says so per row. +- Actions are printed as a checklist with the expected state change and run one by one + after a confirmation. Rows that need a person in view are marked. +- Rows with `warning` (restart, destructive) run only with `--destructive`, last, after + presets and views are printed. Such a row passes when the device returns under the same + id with the expected state. +- It snapshots the state first and restores it through a `trap` on EXIT, INT and TERM: the + snapshot's tracking mode and pose, never hard-coded values. A field whose snapshot value + is `null` is skipped. +- Output: one line per row and a results file. The ledger update takes `measured` and the + date from it. +- CI runs it against `fake-a` and `fake-b`. In stage W4 the release PR maps its results to + every row of 9.1. + +## 10. Way of working + +### 10.1 Branches and rules + +- `main` stays releasable. Until stage G is released it stays at 0.13.x and can ship a patch + at any time. +- Audit fixes that need no camera (tests that pin recorded bytes, docs, CI, logic with unit + tests) land on `main` first, in the 0.13 layout, so that the groundwork move carries them. + Fixes whose acceptance needs the camera wait for it (section 14). +- The rewrite happens on a short-lived branch `rebuild`, started from `main` with this spec + as its first commit. Before its first PR, `ci.yml` runs on `main` and `rebuild` for push + and pull request (the change lands on `rebuild` itself), and a ruleset for + `refs/heads/rebuild` requires signed commits and the `required` check and blocks + deletion. Both are removed after the final merge. +- Each stage or track is one or more PRs into `rebuild`, each with the `required` gate and + an Opus review. +- **Contract-change rule.** A change to the `obsbot-proto` types, the description format, + `trait Camera`, `trait Transport` or the schema goes first as its own small PR into + `rebuild`. Tracks rebase onto it and never change those files inside a track PR. +- When `main` moves (a 0.13.x or 0.14.x patch), `rebuild` is rebased onto it between PRs, + with a note in the tracking issue. Nobody pins a `rebuild` commit hash. +- At the end `main` fast-forwards to `rebuild` and `v1.0.0` is tagged. Before tagging, + `HEAD..origin/main` is empty. + +### 10.2 Stage G: groundwork (released as 0.14.0) + +- **Content:** the cargo workspace (`[workspace.package]` with version, rust-version and + licence; `[workspace.dependencies]`); `crates/obsbot-device` with the carried modules at + their final paths as plain modules (`transport`, `family`, `models/tiny2lite`, + `registry`); `camera.rs` split into transport and Tiny 2 Lite flows; the 0.13 daemon and + CLI unchanged in `crates/obsbot4linux` on top of it; byte tests moved with their code; CI + and packaging for the workspace (package metadata in the binary crate); one mutation-test + pass over the carried modules, survivors fixed or documented. No behaviour change. +- **Owner files:** all; one owner. +- **Depends on:** the camera-free audit fixes on `main`; CI and the ruleset on `rebuild`. +- **Acceptance without camera:** `cargo test --workspace` passes with every 0.13.2 test + present; the diff of the move shows renames and module paths only (reviewed); the CLI's + `--help` output equals 0.13.2's; a packaging run produces the eight artifacts of 12.1 + with the 0.13 binary; the mutation pass is recorded in the PR and kills these mutants: a + rejected answer handled as stale, a rejected tracking query failing the whole read, and + each sign flip in the angle conversions. The tests that kill them are camera-free audit + fixes and land on `main` before stage G (10.1), so stage G itself changes no behaviour. +- **Acceptance with camera (CS-0, CAM-30):** on the real Tiny 2 Lite: `status`, one setting set and + read back, one gimbal move, `preset list`. Then `main` fast-forwards to the last commit of + stage G and `v0.14.0` is tagged. + +### 10.3 Stages of the rewrite + +After stage G is merged into `rebuild` (its release as 0.14.0 may wait for the camera), the +rewrite runs as W0, then four parallel tracks A–D, then W2, W3 and W4. + +**W0: contracts** + +- **Content:** the four crates as compiling stubs; the 0.13 daemon, client, protocol, state + and CLI modules removed from `rebuild` (they stay on `main` for 0.14.x); in + `obsbot-proto` the envelopes, `ErrorCode`, the description types, the validator and the + path helpers; `schema/protocol-2.json` with its stale test; in `obsbot-device` the + signatures of `trait Camera` and `trait Transport`; the fake models `fake-a` and `fake-b`; + the Tiny 2 Lite description as data (every field of 4.3 and 4.4, U-rows included); + `descriptions/tiny2lite.json` and `descriptions/fake/*.json`; golden protocol transcripts + (`hello`, `state` and `devices` events, every error code, an unsupported entry, a + `replaced_by` hand-over); the normative part of `docs/protocol-2.md`. +- **Owner files:** all; one owner. +- **Depends on:** stage G merged into `rebuild`. +- **Acceptance without camera:** the workspace builds and its tests pass; the schema stale + 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`); the + `cargo metadata` check of section 3 passes. +- **Acceptance with camera:** none. + +**Track A: device** + +- **Content:** the USB transport (extension unit by GUID, named timeouts, per-operation + node lock, send-table check), the replay transport, the family code (scripted answer-poll + tests: read error, stale sequence, rejected, empty, no answer), `models/tiny2lite` behind + the trait (identity, poll plan, read, apply, order, act, touched, stops, continuing, send + table), `registry` with the vendor table, `enumeration` (sysfs), and `#[ignore]` live + tests that run only with an explicit environment variable and a connected camera and send + only the model's send table. +- **Owner files:** `crates/obsbot-device` (except the trait files), `descriptions/tiny2lite.json`, + `docs/field-ledger.md`. +- **Depends on:** W0. +- **Acceptance without camera:** every encoder against recorded request bytes; every + 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. +- **Acceptance with camera (CS-1):** the live tests: CAM-1 to CAM-8, CAM-10, CAM-13 to + CAM-20, CAM-22, CAM-23 and CAM-25 (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). + +**Track B: daemon** + +- **Content:** `crates/obsbot4linuxd` as specified in section 5, in two PRs. B-1: the + daemon core with fake mode (`--fake`, test-control socket), so that tracks C and D can run + their acceptance against it. B-2: the real device path (enumeration, registry lookup, + workers over `trait Camera` with `tiny2lite`). +- **Owner files:** `crates/obsbot4linuxd`. +- **Depends on:** W0 (B-1); track A's registry, enumeration and `tiny2lite` (B-2). +- **Acceptance without camera:** named integration tests, all with `fake-a`, `fake-b` and + `unknown` running together: rev order and event-before-reply; pipelined requests; N + pipelined `set`s on one connection cause at most two re-reads; single-leaf coalescing; + stops overtake the queue; whole-patch validation, apply order, `applied`/`failed`; + `requires` gives `conflict`; `pending` appears and clears (lagging readback of `fake-b`, + and the settle bound); `asleep: ignored` refused, `asleep: unknown` written and pending + until wake; the 15 s deadline; `busy` at 32; requests of a closed connection dropped; + `gimbal.turn` stopped on connection close unless `keep`; hotplug add, unplug, re-plug; + `port:` → `sn:` hand-over with `replaced_by`; the unsupported entry receives no frame (the + fake transport records none); `hello` waits for the first enumeration; live-topic + filtering; a slow subscriber is disconnected; the 64 KiB request limit; the 64-connection + limit; the single-instance lock; the panic policy. +- **Acceptance with camera:** none in this track (W4 covers the real path). + +**Track C: client and CLI** + +- **Content:** the client module of `obsbot-proto` (6.1); the CLI (6.2): commands, value + syntax, human output, `--json`, exit codes, device selection, completions and their stale + test, the older-daemon message. +- **Owner files:** `crates/obsbot-proto/src/client*`, `crates/obsbot4linux`, + `packaging/completions/`. +- **Depends on:** W0; B-1 for the tests against a running fake daemon. +- **Acceptance without camera:** value parsing per setting type; rendering snapshots from + the golden states; one test per error code for its exit code; the `--json` error shape for + a usage error; a non-protocol-2 `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. +- **Acceptance with camera:** none in this track. + +**Track D: widget (repository `obsbot4linux-dux`)** + +- **Content:** first, a check that the widget can open the Unix socket from its shell; if + it cannot, track C adds `relay` (6.2) and the widget uses it. Then the widget on protocol + 2: `hello`, device picker, every control gated on a path or action of 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 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. +- **Acceptance without camera:** against `obsbot4linuxd --fake fake-a,fake-b,unknown`: + `fake-b`'s unknown setting does not break the widget; its missing groups hide their + controls; the unsupported device shows as not supported; a value set with the CLI appears + in the widget at once (`pending`); widget 0.28.0 is released before the 1.0 release + candidate. +- **Acceptance with camera:** in W4. + +**W2: hook-up** + +- **Content:** daemon and `tiny2lite` end to end in a dev build; `scripts/check-model.sh` + (9.4); `docs/protocol-2.md` completed with worked transcripts; the migration notes drafted + (12.2). +- **Owner files:** `scripts/`, `docs/`; glue in crates only by contract-change PRs. +- **Depends on:** A, B, C. +- **Acceptance without camera:** the check script passes against `fake-a` and `fake-b` in + CI; the CLI end-to-end tests pass; descriptions and completions are regenerated and their + stale tests pass; no U-row that CS-1 failed is left in `descriptions/tiny2lite.json`. +- **Acceptance with camera:** none. + +**W3: packaging and release candidate** + +- **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. +- **Owner files:** `.github/`, `packaging/` (except completions), `CHANGELOG.md`, + `README.md`, and the support level in the `tiny2lite` registry entry. +- **Depends on:** W2. +- **Acceptance without camera:** a workflow run produces the eight artifacts, and a listing + of each shows both binaries, the units, the completions and the schema; `makepkg` with its + `check()` passes without `XDG_RUNTIME_DIR` (the tests use a fake daemon on a temporary + socket); the evidence-level test of 8.5 passes for `tiny2lite`; the release candidate is + published. +- **Acceptance with camera:** none (W4 runs on this candidate). + +**W4: live parity and release** + +- **Content (CS-2, Thomas present):** install the release candidate on the Arch desktop, + starting from the 0.13 state (socket disabled, the 0.13 daemon a child of the widget's + `watch`) and following the migration notes; run `check-model.sh` on the Tiny 2 Lite, with + `--destructive` last; map its results to every row of 9.1; widget 1.0 against the + candidate; the hardened unit starts, sees the camera, and handles hotplug. +- **Owner files:** release PR (tracking table of 9.1 results), CHANGELOG. +- **Depends on:** W3; D. +- **Acceptance without camera:** none; this stage is the camera session. +- **Acceptance with camera:** CAM-9, CAM-24, CAM-27, CAM-28 (and CAM-26 if a second unit is + at hand); every row of 9.1 verified or an exception of 9.2. A failing row is fixed or + becomes an exception, and a new candidate is built. Then the release follows 10.6. + +### 10.4 Critical path and camera sessions + +Critical path: G → W0 → A → CS-1 → W2 → W3 → W4 (CS-2). B, C and D run beside A; at most +four tracks run in parallel. The camera is needed three times: CS-0 (end of G), CS-1 (track +A) and CS-2 (W4). The Tiny 2 Lite is not available for tests at the moment; stages that need +it wait for it, and everything else goes ahead. + +### 10.5 Milestones (proposal) + +This is a proposal only. No milestone is edited on GitHub before Thomas has seen this +spec, and the plan is first reconciled with the audit's fix plan. + +- **0.14.0, groundwork:** stage G, with #47 and #61 (both in carried code; they land on + `main` before the move, as 10.1 requires, and ship with 0.14.0 unless a 0.13.x patch + ships them first). +- **1.0.0, rebuild:** W0 to W4; #73 (target-box events, by the live topic `target`) and #89 + (start pose, start settings and preset names in the state) move into it. +- **0.15.0 (camera research) and 0.16.0 (other apps):** after 1.0, when a camera is at hand. +- A second camera model never blocks 1.0; it is M4. + +### 10.6 Release day and "done" + +Release day, in this order: + +1. Widget 0.28.0 (the last 0.x release, with its upper bound) is out. +2. The release candidate `v1.0.0-rc.N` is published and W4 has passed on it. +3. `main` fast-forwards to `rebuild`; tag `v1.0.0`; the release workflow publishes the + packages; the mirror follows. +4. Widget 1.0 is released after the tool, the same day. Its `plugin.json` requires + obsbot4linux ≥ 1.0. The plugin registry entry is updated. +5. The 0.13.2 packages and widget 0.28.0 stay downloadable for rollback. The release notes + name them. Rollback beyond that is a fix forward. + +1.0 is done when: + +- every row of 9.1 is verified on the real Tiny 2 Lite or is an exception of 9.2; +- CI is green with `fake-a`, `fake-b` and `unknown` running together; +- the packages are proven by the release candidate (W3); +- widget 1.0 is tested against the release candidate; +- the migration notes are written; +- both walk-throughs (8.4, 8.7) pass on this spec, and walk-through 2 runs in CI with the + `unknown` fake. + +## 11. Testing + +| Level | What | Where | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | +| Camera bytes | encoders and decoders against recorded exchanges on the replay transport; scripted answer-poll tests; angle conversions; send table; registry | `obsbot-device`, CI | +| Descriptions | trait conformance of every model and fake; stale test of `descriptions/`; the evidence-level test (8.5) | `obsbot-device`, CI | +| Protocol | golden transcripts round-trip byte-equal; stale test of `schema/protocol-2.json` | `obsbot-proto`, CI, `makepkg check()` | +| Daemon | the named integration tests of track B, with `fake-a`, `fake-b` and `unknown` together | `obsbot4linuxd`, CI | +| Client and CLI | parsing, rendering snapshots, exit codes, end-to-end against an in-process fake daemon, the 9.1 rows against `fake-a`, completions stale test | `obsbot-proto`, `obsbot4linux`, CI | +| Live, device crate | `#[ignore]` tests behind an environment variable, sending only the send table | `obsbot-device`, CS-1 | +| Real camera | `scripts/check-model.sh` on the Tiny 2 Lite; every row of 9.1 | W4 (CS-2) | +| Mutation | one pass over the carried modules | stage G, recorded in its PR | + +- Without `LISTEN_FDS` the daemon binds `OBSBOT4LINUX_SOCKET` or the default path. Tests run + `obsbot4linuxd::run(Config)` in-process on a temporary socket, in parallel, without + systemd and without `XDG_RUNTIME_DIR`. The fakes are deterministic. +- Golden files cover protocol 2 and the descriptions. An intentional change updates them in + the same PR. +- Mutation tests are not a 1.0 gate. + +## 12. Release and clients + +### 12.1 Packages + +- One package `obsbot4linux` with both binaries, the systemd user units, the bash and zsh + completions (installed to the standard completion directories) and + `/usr/share/obsbot4linux/protocol-2.json`. +- Formats as today: Arch, deb, rpm and tarball, each for x86_64 and aarch64: eight + artifacts. aarch64 builds natively. There is no libudev dependency. +- Packages do not enable the socket unit. The existing mirror stays. + +### 12.2 Migration notes + +The CHANGELOG of 1.0 carries the migration notes: + +- the command map (9.1) and the exceptions (9.2); +- the upgrade steps: `systemctl --user daemon-reload`; end the old daemon + (`systemctl --user stop obsbot4linux.service`, or end the `obsbot4linux daemon` process + that `obsbot4linux watch` started, or log out); remove drop-ins whose `ExecStart` runs + `obsbot4linux daemon`; `systemctl --user enable --now obsbot4linux.socket`; update the + widget together with the tool and reload the shell that hosts it. Setups that never + enabled the socket (the default for widget users) have no daemon otherwise, because 1.0 + 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. + +### 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. + +### 12.4 Desktop app + +The desktop app (`obsbot4linux-desktop`, own repository, own spec) uses `obsbot-proto` (types +and client). Before `v1.0.0` it builds against a local checkout; from then on it depends on +a git tag ≥ `v1.0.0`. Like the widget, it gates every control on the description and shows +the support markers. + +### 12.5 Versions + +All crates carry the workspace version. It follows SemVer for the `obsbot-proto` library API +and for protocol 2: an additive change raises the minor version; a breaking change raises +the protocol number and the major version. + +## 13. Later (not 1.0) + +- **M4:** the first captured second model (Tiny 3 or Tiny 3 Lite, whichever arrives), + through 8.7, plus what it shows the seam lacks. +- **M5:** the device lease for the firmware updater (`busy` is reserved for it); whether the + updater joins the workspace, and the packaging of its udev rule. +- A command catalogue, only once two real models show duplicated tables. +- Models derived only from the vendor app's code are off the roadmap. +- A generated UI (presentation metadata in the description), only with a consumer. +- Live lists per device (1.0 has one list per connection). +- User-given device names, additive, if two identical cameras ever meet. +- More unit hardening (`ProtectSystem`, `PrivateUsers`, `DeviceAllow`), Arch first, each line + accepted only on the real unit. +- Functions 1.0 does not offer: flipping the image, resetting the start pose, resetting the + zone area (each only after CAM-11 or CAM-12 shows that it works), uploading a sleep image. +- Network cameras: a possibility only. An async client, if a client needs it. + +## 14. What only the camera can settle + +Nothing in this list is guessed in the meantime. Each item names the stage that needs it: +G (CS-0), A (CS-1), W4 (CS-2), or M4 onboarding. A stage whose acceptance needs an item +waits for the camera; work that does not need it goes ahead. Every item is answered +without a frame outside the model's send table, with two exceptions: CAM-11 and CAM-12 need +frames that are neither recorded nor sent by 0.13.2, so they wait for a recording of the +vendor app sending them, or for Thomas' decision to try them; they gate nothing. CAM-21 and +CAM-29 are recorded only if they happen and gate nothing. + +**Tiny 2 Lite** + +| CAM | Question | Stage | What the answer decides | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -------------------------------------------------------------- | +| CAM-1 | Does the serial query answer from Linux with 14 characters: awake, asleep, right after plug-in, after the microphone restart, after a factory reset? How long until it answers? | A | identity attempts (5.5) in practice | +| CAM-2 | The `asleep` value of each setting and action; are writes to `audio.volume` and `focus.*` while asleep accepted and kept after wake? | A | `asleep` fields | +| CAM-3 | Is `zoom.factor` ignored while asleep? Is the camera awake at plug-in? How long from wake (or stream start) until the status reports awake? | A | `asleep` of `zoom.factor`; documentation | +| 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-8 | Is the USB audio interface absent on a cold plug with the microphone off? Does the status-block candidate 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` | +| CAM-11 | Does the candidate command flip the image? | none in 1.0 (see above) | `image.flip` (additive, after 1.0) | +| CAM-12 | Do the candidate commands reset the start pose and the zone area? | none in 1.0 (see above) | `power_on.reset`, a zone-area reset (additive, after 1.0) | +| CAM-13 | Order of `exposure.anti_flicker` and `exposure.mode` writes, in both directions | A | apply order (8.8) | +| CAM-14 | After UVC autofocus off, is `focus.mode` `single` or `manual`? | A | rows `focus auto off`, `image autofocus 0` | +| CAM-15 | Are the white-balance temperature and the absolute focus refused while their automatic control is on? | A | confirms the `requires` rules (kept either way: a kernel rule) | +| CAM-16 | Hand and zone area: a new range wholly beyond the stored one | A | U-8 | +| CAM-17 | `gimbal.move` with speed near the tilt limit and the speed ceiling; one axis with speed | A | U-9 | +| CAM-18 | `preset.delete`: which preset does its second (clearing) frame address? Does preset 0 change? (needs the vendor app as a second writer) | A | the frames of `preset.delete` | +| CAM-19 | Preset read: does the list-rewind write have any effect? Can the vendor app's read path (slot list plus one pose query per slot) replace it? | A | the preset read; the `touched` tier stays | +| CAM-20 | While the camera boots, does the first field of the status block read 0 until it is ready? | A | if yes, a not-ready block counts as no read | +| CAM-21 | Can the query channel go silent while the status block still reads? Observed only if it happens; nothing is sent to provoke it. | A, W4 | a rule that ends a whole read early | +| CAM-22 | What does the live-pose query answer while asleep? Do the go-return actions and the pose-taking actions act while asleep? | A | `asleep` of those actions | +| CAM-23 | Every row of 9.3 | A | 9.3 | +| CAM-24 | Every row of 9.1 on the real unit: destructive rows last, tracking rows with a person in view | W4 | release | +| CAM-25 | The sysfs attributes of the camera's USB device and video nodes while plugged (read only: ids, descriptors, interface classes, node index) | A | `enumeration` | +| CAM-26 | Two cameras at once with stable USB paths | W4 if a second unit is at hand | otherwise the fakes cover it | +| CAM-27 | The 1.0 user unit with `NoNewPrivileges=yes` on Arch: starts, sees the camera, handles hotplug | W4 | release | +| CAM-28 | Upgrade rehearsal from the 0.13 desktop state (socket disabled, daemon a child of the widget's `watch`) to 1.0 and widget 1.0, following 12.2 | W4 | migration notes | +| CAM-29 | The kernel's UVC control timeout with an unresponsive camera. Never provoked; recorded if it happens. | any session | `ANSWER_POLL` margin | +| CAM-30 | Smoke test of stage G: `status`, one setting set and read back, one gimbal move, `preset list` | G | release of 0.14.0 | +| 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 | + +**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) + +| CAM | Question | +| ------- | ------------------------------------------------------------------------------------------------------------- | +| CAM-T1 | USB vendor and product ids of the unit, in every mode it shows; does one model show more than one product id? | +| CAM-T2 | Is there a USB serial string? The serial format. | +| CAM-T3 | Extension unit id and GUID, control count, buffer size per selector, video nodes, bcdDevice | +| CAM-T4 | Interface classes (audio, serial, MTP); what a serial interface carries | +| CAM-T5 | Frame layout of requests and answers | +| CAM-T6 | The vendor app's connect sequence and idle poll (they define the identity source and the poll plan) | +| CAM-T7 | Status block: its length and the meaning of each field (one setting changed per step) | +| CAM-T8 | Tracking readback layout; tracking-mode and tracking-speed values | +| CAM-T9 | The settings and actions the vendor app offers for the model, one recording each | +| CAM-T10 | Does the identity query answer, also asleep and right after enumeration? | +| CAM-T11 | Which writes does the camera accept and ignore (readback or visible effect per field)? | +| CAM-T12 | The auto-sleep "off" encoding; does a timer write disconnect USB? (one third-party report, unverified) | +| CAM-T13 | `settle_ms` and `asleep` behaviour per field (step 7) | + +## 15. Changes from revision 1 + +One row per changed decision. **REVERSAL** marks a change to something Thomas approved on +2026-11-01. "Source" names the decision of 2026-11-12 (a letter and number), or "writer" +for a choice made while writing this revision from the review or the audit; each row can be +overruled on its own. Decisions A1–A3 (`audio.pickup` merged into `audio.volume`, the hidden +`gimbal center` not carried, `gimbal.turn` with `keep`) were confirmed as revision 1 proposed +them and are not listed. + +| # | Topic | Revision 1 | Revision 2 | Why | Evidence (status) | Source | +| --- | --------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| 1 | **REVERSAL** Protocol typing | typed `State`, `Patch`, `Action` with per-setting builders | typed envelopes and descriptions; state, patch and parameters are path → JSON value, checked at run time (4.1, 6.1) | typed builders need a proto release for every new setting | the widget hard-codes 53 request sites (verified); new models bring settings the Lite lacks (unknown in detail until captures) | C1 | +| 2 | "No client change" | literal, for all clients | guarantee A: no change to proto, client library or CLI; hand-made UIs update only to show new settings (1) | a hand-made UI cannot show a setting it does not know | widget code (verified) | C2 | +| 3 | **REVERSAL** Crates | five: proto, client, device, daemon, CLI | four: the client is a module of `obsbot-proto` (3) | proto and client are always released together | revision 1 dependency graph (verified) | C3 | +| 4 | Schema artefacts | `schema/protocol-2.json` only; completions "from the schema" | protocol schema plus `descriptions/.json`; the CLI embeds the real models' files for completion (3, 6.2) | path completion needs model data and the CLI must not depend on device code | revision 1 contradiction (verified) | C4 | +| 5 | `--device` completion | completed live while the daemon runs | not completed; Tab never contacts the daemon (6.2) | lean; no daemon contact from the shell | none: design choice | writer | +| 6 | Device entry | `id, name, serial, model, firmware, video_node, available, limits{presets,views}` | adds `support`, `usb` with the descriptor facts, `limits` per list path (4.2) | walk-through 2 and onboarding step 1 need them; `devices --report` prints `usb` | walk-throughs 8.4, 8.7 (verified on paper) | C5; `usb` facts writer | +| 7 | Description fields | type, ranges, `readback`, `asleep`, `no_effect` | adds `requires`, `warning`, `default`, `live`, `settle_ms`, `optional`; bounds per type; closed unit list (4.2) | dependencies, restarts, camera defaults and lag must be visible to generic clients | 0.13 refuses exposure time unless manual (verified); the widget resets to UVC defaults (verified) | C6; `warning` on settings writer | +| 8 | Error codes | unknown path = `invalid`; `busy` also during restarts | adds `conflict`; a path not in the description = `unsupported`; `busy` = queue full only (4.1) | a request the current state forbids had no code | 0.13 refuses an empty preset slot (verified) | C7 | +| 9 | **REVERSAL** `epoch` | random `epoch` in the `hello` reply | none; after any reconnect a client rebuilds from `hello` (4.1) | it had no correct use | none: design choice (a reconnect always rebuilds from `hello`) | C8 | +| 10 | Live list | `live:["pose"]`, pose only | live topics named in the description (`pose`, `target`, `auto`); volatile-only changes go only to subscribers (4.1 rule 8) | auto-adjusted values would send an event every second | 0.13 already suppresses them (verified) | C9 | +| 11 | Serial in output | `--json` shortens the id; `devices --show-serial` | `--json` and `watch` carry the full id; human output and logs shorten; `--show-serial` dropped (4.2, 6.2) | a shortened id cannot be passed back; one contract | revision 1 contradiction (verified) | C10; `--show-serial` writer | +| 12 | `--device` matching | name first, then id | full id, a unique serial suffix of ≥ 4 characters, or a unique model key (6.2) | names are gone | none: follows from row 15 | C10 | +| 13 | **REVERSAL** Start mode "not set" | `power_on.start.mode` nullable, `none` = null | the enum value `unset` (4.3) | null now means "not read" | the widget shows "Not set" for an unread start mode (verified) | C11 | +| 14 | **REVERSAL** `gimbal.step` | an action for widget parity | dropped; the widget computes the target and sends `gimbal.move` (4.4) | the camera has no relative move; the base pose was undefined | the widget steps from its last target (verified) | C11 | +| 15 | **REVERSAL** Names | `device.rename`, `names.json` | none; the daemon keeps no file (2, 5.10) | serves two identical cameras nobody has | none: design choice | C12 | +| 16 | `image.reset` | an action for the widget's reset | dropped; the widget sends a patch of `default` values (4.4, 10.3 D) | no camera command; which controls to reset is a product choice | the vendor app resets other controls than the widget (captured, capture 12) | writer | +| 17 | **REVERSAL** Hold timers | "there are no hold timers" | `pending` (path → written value) with a per-leaf `settle_ms`, checked at poll ticks (4.1 rule 4) | without it the old value flickers back after writes; the wait moves into one place | readbacks lag 1–3 s (measured, #81); target lock about 1 s (measured) | D2, J2 | +| 18 | Write path | read back "the affected group"; full read after each command | re-read only the touched groups; a batch shares one re-read; single-leaf coalescing (4.1 rule 9, 5.6) | a slider drag queued behind a full read per step | 0.13.2 reads everything (about 1.4 s) after every command (verified) | D1 | +| 19 | Stops | no rule; replies strictly in order | stops jump the queue and end a running group read (4.1 rule 2) | a stop waited behind a long read | 0.13.2 behaviour (verified) | D8 | +| 20 | Deadlines | "the transport timeout carried over" | named timeouts; 15 s request deadline; bounded outbound queue; at most 64 connections (5.8, 4.1) | unbounded waits; a slow subscriber stalled replies | 0.13.2 timeouts (verified) | D9; 64 connections writer | +| 21 | Asleep writes | `unknown` passed through | `unknown` written; a path whose readback cannot run asleep stays pending until wake (5.7) | the write could never be confirmed | volume and focus readbacks do not answer asleep (0.13.2 behaviour, verified) | D3 | +| 22 | Identity | 3 tries over about 10 s, also after wake; `port:` entries kept | sent at plug-in also asleep; 3 attempts with plug-in and the next two 5 s reads; one series after the first wake; serial format check; duplicate → `port:`; `port:` entries removed on unplug (5.5) | bounded, tied to existing reads; an id changes at most once | serial query asleep: unknown (CAM-1) | D4, J4; duplicates and `port:` removal writer | +| 23 | Unknown devices | undefined; "read model and serial" for every device | listed `unsupported`, nothing sent, `devices --report` (5.4, 8.4) | any frame to an unknown device is a probe | a blind query scan broke the query channel once (measured) | D5 | +| 24 | **REVERSAL** Hotplug | a udev monitor | a sysfs scan once a second, no libudev, no netlink (5.3) | no C dependency; 0.13 already rescans every second | 0.13.2 daemon (verified) | D6 | +| 25 | Start-up | undefined | `hello` answered after the first enumeration; a device announced after its first read (5.2) | a socket-activated `status` saw no camera | revision 1 gap (verified) | D7 | +| 26 | Single instance | lock at `$XDG_RUNTIME_DIR/obsbot4linux.lock`; turn stops on shutdown | lock at `.lock` for every daemon; a clean shutdown stops a turn best effort; a crash leaves the camera (5.9) | test daemons collided; the 0.13 daemon uses the same lock name | 0.13.2 lock (verified) | D10 | +| 27 | **REVERSAL** Hardening | `NoNewPrivileges`, `ProtectSystem`, `DeviceAllow`, tested on Arch and Ubuntu 24.04 | `NoNewPrivileges=yes` only; the rest after 1.0 (5.10) | independent of the rebuild and costly to accept | in user units these lines imply `PrivateUsers` (verified, systemd documentation); no run on the real unit yet (verified) | D11 | +| 28 | **REVERSAL** Mutation tests | a stage-5 gate on device and proto | one pass over the carried code in stage G; no 1.0 gate (10.2, 11) | they test carried code, which moves first | seven sign-flip mutants survive in the carried code (verified) | D11 | +| 29 | Daemon start | "nothing needs re-enabling" | socket only; packages enable nothing; exit 4 prints the enable line; an older daemon is detected (5.10, 12.2) | default widget setups run the daemon as a child of `watch` with the socket disabled | the owner's desktop (verified) | D12 | +| 30 | 0.13 state files | the daemon deletes `state.json`; the guide promised an import | the daemon touches no file; the migration notes say the directory can be deleted (12.2) | "keeps no file at all"; no import exists | revision 1 contradiction (verified) | writer (C12) | +| 31 | Never-send | a deny-list of two ids | a per-model send table checked in the transport, plus refused classes; no raw path (8.3) | a deny-list protects only against the ids it names | verified | E1 | +| 32 | Public text | text that 8.5 does not allow | only what 8.5 allows; evidence by level and opaque key | the repository is public | verified | E2, J8 | +| 33 | **REVERSAL** Fakes | `--fake N`: N identical Tiny 2 Lites with test-only actions | `fake-a`, `fake-b` (deliberately different), `unknown`; test control on a second socket (5.12) | mixed models and unknown devices were never tested | revision 1's fakes cannot exercise mixed models or an unknown device (verified) | F1–F3 | +| 34 | **REVERSAL** M4 and the catalogue | M4 = TOML catalogue plus a model derived from the vendor app | no catalogue; models only with a unit and captures; M4 = the first captured second model (8.7, 13) | "common commands" cannot be assumed | the vendor app treats models differently in many functions (verified); identity-query loops linked to USB drops on a Tiny 3 (one third-party report, unverified) | B3, B4, B5, J7 | +| 35 | Tiny 3 family | – | two models, two modules; nothing built before a unit is at hand; every device fact unknown until captures (8.9) | the two differ in the vendor app | verified for the vendor app; unknown for the devices | B5, J1 | +| 36 | Registry key | "its USB id" | USB (vendor id, product id) pairs, several per model; a vendor table of measured ids only (8.1) | a model may have several product ids | none: design choice | B6 | +| 37 | Extension unit | unit 2 hard-coded | found by GUID in the USB descriptor, read-only (8.3) | no per-model constant, no request | the descriptor holds unit and GUID (captured, capture 01) | B7 | +| 38 | Model seam | one sentence; the daemon section named Lite ids and tiers | three layers in `obsbot-device`; a path-keyed `trait Camera`; the daemon names no id (3.1, 5, 8.2) | per-model data plus per-model flows; a daemon that knows the Lite cannot run another model | about 83 family-generic and 64 Lite-specific knowledge items in the carried code (verified count) | B1, B2 | +| 39 | Live pose query | the poll tier named the query that arms the zone switch | the model uses `0x0043`; the daemon names no query (8.8) | that query arms the switch to static after a zone return | #67 (measured) | B2 (writer) | +| 40 | **REVERSAL** (partial) Delivery | a long-lived `rebuild` branch, five serial stages | stage G first (released as 0.14.0 from `main`); W0, four parallel tracks, W2, W3, W4 (10) | 64 % of the lines are carried; fixes would land twice; packaging first ran on the tag | line count (verified) | H1, H2, J5 | +| 41 | CI on `rebuild` | assumed | `ci.yml` and a ruleset for `rebuild` before its first PR (10.1) | PRs into `rebuild` ran no CI | GitHub settings (verified) | J5 | +| 42 | Widget channel | undefined; `watch` relayed requests in 0.13 | the widget speaks protocol 2 on the socket; `watch` prints events; `relay` as fallback (6.2, 10.3 D) | one contract; no protocol-1 adapter | a socket type in the shell's runtime: unknown | H3 | +| 43 | Release day | widget and tool the same day | widget 0.28.0 with an upper bound first; a release candidate; a "done" list (10.6) | widget 0.27 would accept 1.0 and fail at run time | widget start-up check (verified) | H4 | +| 44 | Milestones | 0.14.0–0.16.0 on `main` as planned on 2026-10-31 | a proposal: 0.14.0 = groundwork, #73 and #89 into 1.0, 0.15.0 and 0.16.0 after 1.0 (10.5) | the groundwork release carries the carried-code fixes | none: planning choice | H5, J6 | +| 45 | Desktop app dependency | git tag; shell starts after stage 1 | a local checkout before `v1.0.0`, a git tag ≥ `v1.0.0` after; SemVer rules (12.4, 12.5) | no tag exists before 1.0 | no tag exists before `v1.0.0` (verified) | H6; checkout writer | +| 46 | Evidence rule | "every function of 0.13 stays" | only backed fields are offered; unbacked Lite fields are checked or become named exceptions (9.3) | the two rules collided for fields without backing | ledger levels (verified) | J3 | +| 47 | Parity script | `check-parity.sh`, set → read back → restore for every row | `check-model.sh` from the description, `--device`, destructive rows last, restore by trap (9.4) | not every row can be restored; a second model has no 0.13 rows | 0.13's readback script restores incompletely (verified) | writer | +| 48 | `image.gamma` | a path, "incl. gamma" | removed; PX-2 | the camera has no gamma control | the recorded UVC descriptor (captured) | writer | +| 49 | Exposure compensation | `image.backlight_compensation`; "a guess" | `exposure.compensation`, the raw value 0–18, default 9; EV only after U-20 | the vendor app's exposure compensation writes this control; its scale is inferred | captured, capture 12; the 1/3 EV step unverified | writer | +| 50 | Anti-flicker | `auto` writable | `auto` read-only; no `default` (4.3) | the kernel driver offers 0–2 on this camera | kernel driver (verified); the camera reports `auto` (measured) | writer | +| 51 | Sleep background | `black\|image` | values add `own_image` (read-only) | the camera reports it after an upload | measured | writer | +| 52 | `gimbal.move` | `pan` and `tilt` required | either may be missing; the camera keeps that axis (4.4) | a pan-only move pulled a stale tilt | 0.13.2 behaviour (verified); the keep value (captured) | writer | +| 53 | Current pose | only `preset.save` and `view.save` named the live pose | every pose-taking action uses the live pose and is refused while asleep until CAM-22 (4.4) | 0.13 used a remembered or V4L2 pose | 0.13.2 code (verified) | writer | +| 54 | Target box | layout an open point; read every second | layout settled; box changes go to the live topic `target` (4.3) | the layout was checked over the preview | #71 (measured) | writer, C9 | +| 55 | Preset read | in the 60 s tier | at plug-in and after the actions that change presets (`touched`, 8.8) | the read writes to the camera; the vendor app never does | captured (no such write in any recording) | writer | +| 56 | `do wake` | replies once the status reports awake | replies on acceptance; the state shows `asleep` (4.4) | an unbounded wait in the worker | wake time unknown (CAM-3) | writer | +| 57 | Null and unread | `null` only for `nullable` | `unread` leaf paths, `raw:` values, `readback:false` lifetime per availability (4.2) | "not set" and "not read" were indistinguishable | 0.13 widget shows "Not set" for unread values (verified) | writer | +| 58 | `audio.enabled` | no readback; `busy` during the restart after "off" | readback from the USB audio interface; restart on both values; reply on acceptance (4.3, 4.4) | the restart is hotplug; a sysfs fact is camera state | the interface follows the toggle (measured); restart on both values (measured) | writer, C7 | +| 59 | Value details | zoom-move speed a raw integer; exposure time "steps in 100 µs" | zoom-move speed `slow\|medium\|fast`; no step promise for exposure time; zoom ratio precision 0.1; `sleep.auto.seconds` 1–32767; values rounded at the source | only known values are offered; the readback limits precision | 0.13.2 code (verified) | writer | +| 60 | Write order | anti-flicker before manual, citing a 0.13 function | `requires` paths first; tracking mode before framing; the anti-flicker order is CAM-13 (8.8) | the cited function has no such step | 0.13.2 code (verified) | writer | +| 61 | On air | `{active, apps[]}` | one sample; a client's own preview counts; kernel note (4.3) | 0.13 read the two at different moments | 0.13.2 code and kernel source (verified) | writer | +| 62 | `available` | from hotplug | only from enumeration; a failed read never flips it (5.3) | 0.13 reported "camera gone" for one failed tick | 0.13.2 daemon (verified) | writer | +| 63 | Poll intervals | carried from 0.13 | counted from the end of the previous read (5.7) | a slow read starved the 1 s tier | 0.13.2 daemon (verified) | writer | +| 64 | `view.save` name | optional | required, as in 0.13 (4.4) | 0.13 requires it; the camera's default is not recorded | 0.13.2 code (verified) | writer | +| 65 | Untested models | marked UNTESTED in docs | a `support` field; no destructive actions; never called supported (8.6) | a field was missing | none: design choice | C5, writer | +| 66 | `--json` errors | not defined for usage errors | `{"error":{"code","message"}}` on stdout, exit 2 (6.2) | clap printed text | 0.13.2 CLI (verified) | writer | +| 67 | Licence note | names the project the code came from | a general rule: the EUPL note follows the code into every file (3.3) | split and copied code must keep it | none: licence obligation | writer | From 99cba501ca50a8cb3503949efbccbf148afa6b6b Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Thu, 12 Nov 2026 22:05:06 +0100 Subject: [PATCH 02/27] Bring the field ledger in line with spec revision 2 One row per leaf of spec 4.3 and per action of spec 4.4, with the paths, values and actions of revision 2. Each row gives the write, the readback, the evidence as a level with an opaque key, the spec 9.3 row, the asleep behaviour and settle_ms. References go to spec sections. Rows for dropped functions are gone (gamma, gimbal.step, image.reset, device.rename, gimbal.center), as are the review findings of revision 1. Citations of private notes are replaced by capture keys, measurement dates and public issues. A closing section lists where the recorded evidence differs from spec 9.3 and 8.8, for the spec editor. Co-Authored-By: Claude Opus 5.5 --- docs/field-ledger.md | 180 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/field-ledger.md diff --git a/docs/field-ledger.md b/docs/field-ledger.md new file mode 100644 index 0000000..8b1d531 --- /dev/null +++ b/docs/field-ledger.md @@ -0,0 +1,180 @@ +# Field ledger: Tiny 2 Lite + +Revision 2 (2026-11-12), for revision 2 of the 1.0 design spec +(`docs/superpowers/specs/2026-11-01-obsbot4linux-1.0-design.md`). One row per leaf of spec §4.3 +and per action of spec §4.4. The rules for this file are in spec §8.5; the model notes are in +spec §8.8. Nothing here was sent to a camera to write it. + +**Wire notation.** `short XX LL `: short command on extension-unit selector 6 (command, length, +value). `framed 0xNNNN`: framed command to receiver 4 unless `rcv` names another; payloads are +little-endian. `query 0xNNNN`: framed query and its answer. `status 0xNN`: byte at that offset of +the status block (selector 6 read). `0x0104 byte N`: byte N of the answer to query `0x0104`. +`UVC CT` / `UVC PU`: standard UVC camera-terminal and processing-unit controls, through V4L2. +On the wire, pitch is positive down and yaw positive to the camera's left; the model converts to +the units of spec §4.2. Preset layout: 24 bytes, `u32 lead, f32 yaw, f32 pitch, f32 0, f32 zoom, +f32 last` (`last` −1000 for presets and views, 0 for start pose and returns). + +**Evidence** (the levels of spec §1). `capture N`: recorded from the vendor app in capture N. +`measured `: measured on the camera on that date; `#N` names the public issue or pull +request that records it. `none`: no backing today. `daemon`: not a camera value. Column §9.3: the +row of spec §9.3 that the spec attaches to the field; where that row and the evidence column +disagree, the last section lists it. + +**Asleep** (spec §5.7): `ignored`, `accepted` or `unknown`; `–` for read-only leaves. "Not read +asleep": the readback is not sent while the camera sleeps, so a write stays pending until wake. +**`readback:false`** shows as `none` in the readback column. **`settle_ms`** is 3000 for every +writable leaf with a readback: one model-wide bound from the worst measured case (zone tracking +follows 1–3 s after the write, #81; the target-lock status about 1 s). Per-leaf values are CAM-5. + +## Identity (spec §5.5, §8.8) + +| Value | Query | Evidence | +| ---------- | ----------------------------------------------------- | ----------------------------- | +| `serial` | query `0x18c8` rcv `0x0d`: 14 ASCII characters | capture 01; Linux read: CAM-1 | +| `firmware` | query `0x0408` rcv `0x0d`: u32, shown byte 3.2.1.0 | capture 01 | +| `model` | query `0x1948` rcv `0x0d`: product and platform codes | capture 01 | + +## State (spec §4.3) + +| Path | Write | Readback | Evidence | §9.3 | Asleep | settle_ms | +| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ---------------- | ----------------------------- | --------- | +| `available` | – | sysfs enumeration (spec §5.3) | daemon | | – | – | +| `pending` | – | spec §4.1 rule 4 | daemon | | – | – | +| `unread` | – | spec §4.2 | daemon | | – | – | +| `asleep` | actions `sleep`, `wake` | status `0x02`: 0 awake, 1 asleep | capture 13; measured 2026-10-24 | | – | – | +| `on_air.active` | – | buffer request on the video node: busy while another reader holds the stream | measured 2026-10-25 | | – | – | +| `on_air.apps` | – | names of processes that hold the node open, from `/proc`, best effort | measured 2026-10-25 | | – | – | +| `gimbal.pan` (live `pose`) | – (actions) | query `0x0043` rcv 3: bytes 10–11, i16 yaw, 0.01° | measured 2026-10-31 (#67) | | – | – | +| `gimbal.tilt` (live `pose`) | – (actions) | query `0x0043` rcv 3: bytes 8–9, i16 pitch, 0.01° | measured 2026-10-31 (#67) | | – | – | +| `gimbal.invert` | framed `0x3b84`, u8 0/1 | `0x0104` byte 11 | capture 05, 17 | | unknown | 3000 | +| `zoom.factor` | UVC CT zoom absolute, 0–100 for 1.0–4.0 | query `0x6804`, f32 (V4L2 keeps the last written value) | capture 04, 11; measured 2026-10-24 | | unknown (CAM-3) | 3000 | +| `zoom.field_of_view` | short `04 01 `: 0 wide, 1 medium, 2 narrow | status `0x11`; 3 = `custom` | capture 09; measured 2026-10-24 | | ignored (measured 2026-10-24) | 3000 | +| `tracking.mode` | short `16 02 `: static `00 00`, group `01 00`, normal `02 00`, upper_body `02 01`, close_up `02 02`, headless `02 03`, lower_body `02 04`, hand `03 00` | status `0x18`, `0x1c` | capture 11 | | unknown | 3000 | +| `tracking.profile` | framed `0x0cc4`, u8: 0 standard, 1 headroom, 2 sport | status `0x24` | capture 11 (0, 2); measured 2026-10-24 (0, 2), 2026-10-31 (#98: 1) | U-2 (`headroom`) | unknown | 3000 | +| `tracking.speed` | framed `0x0944`, u8 0–5: lazy, slow, normal, fast, very_fast, auto | query `0x0984`, u8 | measured 2026-10-31 (#98: read back; effect not observed) | U-1 | unknown | 3000 | +| `tracking.target.detected` | – | query `0x01c4`, sent only while `tracking.mode` is not `static` | measured 2026-10-25 | | – | – | +| `tracking.target.box` (live `target`) | – | query `0x01c4`: f32 left, top, right, bottom, picture fractions | measured 2026-10-25; layout #71 | | – | – | +| `framing.auto_zoom` | framed `0x0a44`, u8 0/1 | query `0x0a84`, u8 | measured 2026-10-31 (#98: read back; effect not observed) | U-3 | unknown | 3000 | +| `framing.size` | framed `0x0ac4`, u8 0–5: long_shot … big_close_up | query `0x0b04`, u8 | measured 2026-10-31 (#98: read back; effect not observed) | U-3 | unknown | 3000 | +| `framing.zoom_speed` | framed `0x0b44`, u8: 0 slow, 1 medium, 2 fast | query `0x0b84`, u8; 10, 30 or 50 while never set | measured 2026-10-31 (#98: read back; effect not observed) | U-3 | unknown | 3000 | +| `framing.offset.x` | framed `0x0bc4`, f32 −0.16..0.16 | query `0x0c04`, f32 | measured 2026-10-31 (#98: read back; bounds and direction not checked) | U-4 | unknown | 3000 | +| `framing.offset.y` | framed `0x0c44`, f32 −0.16..0.16 | query `0x0c84`, f32 | measured 2026-10-31 (#98: read back; bounds and direction not checked) | U-4 | unknown | 3000 | +| `framing.part` | framed `0x1584`, u8: 0 normal, 1 head_removal, 2 lower_body | query `0x15c4`, u8 | measured 2026-10-31 (#98: read back; effect not observed) | U-3 | unknown | 3000 | +| `gestures.target_lock` | framed `0x30c4`, u8 0/1 | `0x0104` byte 3; status `0x01` follows about 1 s later | capture 04; measured 2026-10-24 | | unknown | 3000 | +| `gestures.zoom` | framed `0x3144`, u8 0/1 | `0x0104` byte 4 | capture 04; measured 2026-10-24 | | unknown | 3000 | +| `gestures.zoom_ratio` | framed `0x3244`, f32 1.0–4.0 | `0x0104` byte 7, tenths | capture 04; measured 2026-10-24 | | unknown | 3000 | +| `gestures.dynamic_zoom` | framed `0x3344`, u8 0/1 | `0x0104` byte 5 | capture 04; measured 2026-10-24 | | unknown | 3000 | +| `gestures.direction_change` | framed `0x33c4`, u8 0/1 | `0x0104` byte 6 | capture 04; measured 2026-10-24 | | unknown | 3000 | +| `zone.enabled` | framed `0x08c4`, u8 0/1 | `0x0104` byte 10 | capture 11; measured 2026-10-31 (#81) | | unknown | 3000 | +| `zone.follow` | framed `0x4e04`, u8 0/1 | query `0x4e44`, u8 | capture 11, 18; measured 2026-10-25 | | unknown | 3000 | +| `zone.area.pan` | framed `0x4c04` (min), then `0x4c84` (max), f32 yaw | queries `0x4c44`, `0x4cc4`, f32 | capture 11; measured 2026-10-25, 2026-10-27 (#58) | U-8 (order) | unknown | 3000 | +| `zone.area.tilt` | framed `0x4d04` (min), then `0x4d84` (max), f32 pitch: the bounds swap and change sign | queries `0x4d44`, `0x4dc4`, f32 | capture 11; measured 2026-10-25, 2026-10-27 (#58) | U-8 (order) | unknown | 3000 | +| `zone.return` | – (actions) | query `0x4ec4`, preset layout; the lead is always 0, so never `null` | capture 11, 18; measured 2026-10-25 | | – | – | +| `hand.side` | framed `0x2244`, u8: 1 left, 0 right | `0x0104` byte 2 | capture 11, 17 | | unknown | 3000 | +| `hand.area.pan` | framed `0x2044` (min), then `0x20c4` (max), f32 yaw | query `0x2004`: f32 0 and 1 | capture 11 | U-8 (order) | unknown | 3000 | +| `hand.area.tilt` | framed `0x2144` (min), then `0x21c4` (max), f32 pitch: the bounds swap and change sign | query `0x2004`: f32 2 and 3 | capture 11 | U-8 (order) | unknown | 3000 | +| `hand.axes.pan` | framed `0x2344`, u8 0/1 | query `0x2004` byte 22 | none | U-5 | unknown | 3000 | +| `hand.axes.tilt` | framed `0x23c4`, u8 0/1 | query `0x2004` byte 23 | none | U-5 | unknown | 3000 | +| `hand.return_after` | framed `0x2444`, f32 seconds | query `0x2484`, f32 | none | U-6 | unknown | 3000 | +| `hand.move_gimbal` | framed `0x26c4`, u8 0/1; the camera does not store it | none | capture 11 | | unknown | – | +| `hand.return` | – (actions) | query `0x2504`, preset layout; lead −1 = none, shown as `null` | capture 11; measured 2026-10-25 | | – | – | +| `presets` | – (actions) | selector `0x0c` read (count, slots) and written back to rewind, then selector `0x0d` once per preset: slot, pitch, yaw, zoom, base64 name | measured 2026-10-31 (#104) | | – | – | +| `views` | – (actions) | query `0x1344` (ids; an answer without data = none), then per id `0x1504` (name) and `0x1444` (pose) | capture 11, 18; measured 2026-10-31 (#100: empty list) | U-14 | – | – | +| `exposure.mode` | UVC CT auto-exposure mode: `manual` or automatic | V4L2 `auto_exposure` | measured 2026-10-31 (#97) | | unknown | 3000 | +| `exposure.region` | short `03 01 `: 0 global, 1 face | status `0x07` | capture 12; measured 2026-10-24 | | unknown | 3000 | +| `exposure.time` (live `auto`) | UVC CT exposure time absolute, 100 µs units; requires `exposure.mode=manual`; the camera takes the step at or below | V4L2 | measured 2026-10-31 (#97) | | unknown | 3000 | +| `exposure.gain` (live `auto`) | UVC PU gain, 1–64 | V4L2 | capture 12 | | unknown | 3000 | +| `exposure.compensation` | UVC PU backlight compensation, raw 0–18, default 9 | V4L2 | capture 08b, 12 | U-20 (EV scale) | unknown | 3000 | +| `exposure.hdr` | short `01 01 ` | status `0x06` | capture 12; measured 2026-10-24 | | unknown | 3000 | +| `exposure.anti_flicker` | UVC PU power line frequency: 0 off, 1 50hz, 2 60hz | status `0x03`; 3 = `auto` | capture 12; measured 2026-10-24 (0–2); value `auto`: none | | unknown | 3000 | +| `image.brightness` | UVC PU brightness | V4L2 | measured 2026-10-24 | | unknown | 3000 | +| `image.contrast` | UVC PU contrast | V4L2 | capture 08b, 12; measured 2026-10-24 | | unknown | 3000 | +| `image.saturation` | UVC PU saturation | V4L2 | capture 08b, 12; measured 2026-10-24 | | unknown | 3000 | +| `image.hue` | UVC PU hue | V4L2 | capture 08b, 12 | | unknown | 3000 | +| `image.sharpness` | UVC PU sharpness | V4L2 | capture 08b, 12; measured 2026-10-24 | | unknown | 3000 | +| `image.white_balance.auto` | UVC PU white balance temperature auto | V4L2 | capture 08b, 12 | | unknown | 3000 | +| `image.white_balance.temperature` (live `auto`) | UVC PU white balance temperature, K; requires `image.white_balance.auto=false` | V4L2 | capture 08b, 12 | | unknown | 3000 | +| `image.mirror` | short `14 01 ` | status `0x13` | capture 05; measured 2026-10-24 | | unknown | 3000 | +| `image.frame_rate` | – | status `0x1f`: 30 or 60 | measured 2026-10-24 | | – | – | +| `focus.mode` | framed `0x3442` rcv 2, u32: 1 continuous, 2 single, 3 manual; UVC CT focus auto sets the same continuous bit | query `0x3642` rcv 2, word 0; status `0x0e` is 1 only for continuous | capture 12 (focus auto); measured 2026-10-31 (#97) | U-15 | unknown; not read asleep | 3000 | +| `focus.region` | framed `0x3602` rcv 2, u32: 0 global, 1 face | status `0x0d` | capture 12; measured 2026-10-24 | | unknown | 3000 | +| `focus.position` | UVC CT focus absolute, 0–100; requires `focus.mode=manual` | query `0x3642` rcv 2, word 1 (status `0x0f` too) | capture 12; measured 2026-10-31 (#97: read back; #99: no visible effect) | U-22 | unknown; not read asleep | 3000 | +| `audio.enabled` | short `1c 01 `; the camera restarts on both values, about 18 s | the camera's USB audio interface present in sysfs | capture 14; measured 2026-10-24 (restart; the interface goes and returns) | U-23 (readback) | unknown | 3000 | +| `audio.volume` | framed `0x8102` rcv 2, i16 0–100 | query `0x80c2` rcv 2, i16 | measured 2026-10-31 (#97: 30, 55, 85 read back) | U-17 | unknown; not read asleep | 3000 | +| `audio.noise_reduction` | short `0a 01 `: 0 off, 1 weak, 2 medium, 3 strong | status `0x08` | capture 06; measured 2026-10-24 | | unknown | 3000 | +| `audio.auto_gain` | short `17 01 ` | status `0x19` | capture 06; measured 2026-10-24 | | unknown | 3000 | +| `audio.in_sleep` | short `13 01 ` | status `0x10` | capture 06; measured 2026-10-24 | | unknown | 3000 | +| `led.brightness` | short `1a 01 `, 0–3 | status `0x21` | capture 07; measured 2026-10-24 | | unknown | 3000 | +| `led.blink` | short `18 01 ` | none | capture 11, 18; measured 2026-10-25 (the LED blinks) | | unknown | – | +| `sleep.auto.enabled` | short `0b 02 `, one word with `sleep.auto.seconds`: negative = off, the seconds are kept | status `0x0a`, i16 | capture 02; measured 2026-10-24 | | unknown | 3000 | +| `sleep.auto.seconds` | the same word, 1–32767 | status `0x0a`, i16 | capture 02; measured 2026-10-24 | | unknown | 3000 | +| `sleep.background` | short `0e 02 00 `: 0 black, 1 image | status `0x1b`; 3 = `own_image` | capture 03, 18 (`own_image`); measured 2026-10-24 | | unknown | 3000 | +| `sleep.background_mirror` | short `0e 02 02 ` | status `0x1d` | capture 03; measured 2026-10-24 | | unknown | 3000 | +| `sleep.wake_zoom` | short `15 02 64 `, v 0–100 for 1.0–4.0 | status `0x16`, u16 | measured 2026-10-31 (#97: read back; #102: no effect) | U-21 | unknown | 3000 | +| `power_on.mode` | short `19 01 `, v = m << 4 \| n of the `16 02` pair | status `0x20` | measured 2026-10-31 (#97: read back; #102: applied at power-on) | | unknown | 3000 | +| `power_on.start.source` | – (actions) | query `0x3884`, lead: 3 = `own`, 0–2 = `preset_` | capture 08; measured 2026-10-25 (`own`); `preset_`: none | | – | – | +| `power_on.start.pose` | – (actions) | query `0x3884`, preset layout | capture 08; measured 2026-10-25 | | – | – | +| `power_on.start.mode` | framed `0x3e44`: the block read by query `0x3e84` with byte 0 changed: static `ff`, normal `00`, upper_body `01`, headless `03`, group `09`, unset `fe` | query `0x3e84` byte 0; also close_up `02`, lower_body `04`, hand `07` or `08` | capture 08 (normal, unset) | U-18 (others) | unknown | 3000 | +| `power_on.settings.tracking_speed` | – | query `0x3e84` byte 1; `ff` = not set | capture 08b (one value) | U-19 | – | – | +| `power_on.settings.autofocus` | – | query `0x3e84` byte 2; `ff` = not set | capture 08b (one value) | U-19 | – | – | +| `power_on.settings.auto_exposure` | – | query `0x3e84` byte 6; `ff` = not set | capture 08b (one value) | U-19 | – | – | +| `power_on.settings.exposure_compensation` | – | query `0x3e84` byte 8 | capture 08b (one value) | U-19 | – | – | +| `power_on.settings.white_balance_mode` | – | query `0x3e84` byte 13; `ff` = not set | capture 08b (one value) | U-19 | – | – | +| `power_on.settings.white_balance_temperature` | – | query `0x3e84` bytes 14–15, u16 K, with mode 5 | capture 08b (one value) | U-19 | – | – | +| `power_on.settings.contrast` | – | query `0x3e84` byte 20; `ff` = not set | capture 08b (one value) | U-19 | – | – | +| `power_on.settings.saturation` | – | query `0x3e84` byte 21; `ff` = not set | capture 08b (one value) | U-19 | – | – | +| `power_on.settings.sharpness` | – | query `0x3e84` byte 22; `ff` = not set | capture 08b (one value) | U-19 | – | – | +| `power_on.settings.hue` | – | query `0x3e84` byte 23; `ff` = not set | capture 08b (one value) | U-19 | – | – | + +## Actions (spec §4.4) + +"Live pose" below means query `0x0043` rcv 3 plus query `0x6804`, as for `gimbal.pan`, +`gimbal.tilt` and `zoom.factor`. + +| 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.stop` | framed `0x6704`, no data | the live pose stops changing | measured 2026-10-31 (#98) | U-10 | 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) | +| `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) | U-11 | 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.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 | +| `view.save` | live pose unless `pose` is given; `views` read for a free id; framed `0x1384`: preset layout with the id; framed `0x1544` 100 ms later: u32 id, ASCII name | `views`; result `{id}` | capture 11 | | ignored (CAM-22) | +| `view.go` | framed `0x1604`: u32 id | live pose, `zoom.factor` | measured 2026-10-31 (#100) | U-13 | unknown | +| `view.rename` | framed `0x1544`: u32 id, ASCII name | `views` | capture 11 | | unknown | +| `view.delete` | framed `0x13c4`: u32 id | `views` | capture 11 | | unknown | +| `zone.return_here` | live pose; framed `0x4e84`: preset layout, lead 0 | `zone.return` | capture 11; measured 2026-10-25 | | ignored (CAM-22) | +| `zone.go_return` | framed `0x4f44`, no data | the gimbal moves to `zone.return` | measured 2026-10-25 | | unknown (CAM-22) | +| `hand.return_here` | live pose; framed `0x24c4`: preset layout, lead 0 | `hand.return` | capture 11; measured 2026-10-25 | | ignored (CAM-22) | +| `hand.go_return` | framed `0x2584`, no data | the gimbal moves to `hand.return`; to pan 0, tilt 0 while none is set | measured 2026-10-25 | | unknown (CAM-22) | +| `hand.return_clear` | framed `0x2544`, no data | `hand.return` becomes `null` | measured 2026-10-25 | | unknown | +| `hand.area_reset` | framed `0x25c4`, `0x2604`, `0x2644`, `0x2684`, no data each | `hand.area` back to pan −45..45, tilt −30..30 | none | U-7 | unknown | +| `focus.point` | framed `0x3582` rcv 2: f32 x, f32 y, u32 1, u32 100, u32 100 | `focus.mode` turns `single`; the point in query `0x3642` words 2–3 | measured 2026-10-31 (#97: accepted, point read back; #99: the picture blurs instead of focusing) | U-16 | unknown | +| `power_on.set_pose` | framed `0x3ec4`: preset layout, lead 3; framed `0x3e44` 100 ms later: the block read by query `0x3e84`, unchanged | `power_on.start` | capture 08; measured 2026-10-25 (readback) | | unknown | +| `power_on.use_current` | live pose; then the frames of `power_on.set_pose` | `power_on.start` | capture 08 | | ignored (CAM-22) | +| `power_on.use_preset` | `presets` read for the slot's pose; framed `0x3ec4`: preset layout, lead = slot; framed `0x3e44` as in `power_on.set_pose` | `power_on.start` | capture 10 | | unknown | +| `power_on.go` | short `00 01 01` | the gimbal moves to the start pose; with `power_on.start.mode=unset` tracking stays suspended | measured 2026-10-31 (#79) | U-24 | ignored (measured 2026-10-31, #79) | +| `sleep` | framed `0xa0c2` rcv 2, u32 1 | `asleep` true | capture 13; measured 2026-10-24 | | unknown | +| `wake` | framed `0xa0c2` rcv 2, u32 0 | `asleep` false | capture 13; measured 2026-10-24 | | accepted (capture 13) | +| `factory_reset` | framed `0xa802` rcv 2, u8 `01` | the camera restarts and re-enumerates; the presets are deleted | capture 16; measured 2026-10-24 | | unknown (CAM-9) | + +## Open against the spec + +The spec has not taken these ledger results yet. Each line names the spec section to change. + +- §9.3, backed today (write and readback, or the effect, measured): U-1 and U-3 (#98; the effect + on tracking is not observed), U-2 for the readback of `headroom` (#98; its effect is not + observed), U-10 and U-11 (#98), U-13 (#100), U-15 (#97), U-17 (#97), U-21 and U-22 (read back, + #97; no effect, #102 and #99: they stay `no_effect`), U-24 (#79). +- §9.3, narrower than stated: U-4 (values read back, #98; the bounds and the direction are open), + U-14 (the three reads are in capture 11 and 18), U-19 (each leaf is in capture 08b with one + value; the open part is the meaning of other values), U-16 (accepted and read back, #97; the + effect is wrong, #99). +- §9.3, missing: the value `auto` of `exposure.anti_flicker` (status `0x03` = 3 never recorded; + §15 row 50 says measured) and the values `preset_` of `power_on.start.source` (a start + pose taken from a preset was never read back). +- §8.8: `power_on.go` is `ignored` asleep by measurement (#79), and `wake` is `accepted` asleep + (capture 13). From 1f8bc1e7716fce6d81eb28158eda7652d8e7a896 Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Thu, 12 Nov 2026 22:44:41 +0100 Subject: [PATCH 03/27] Spec revision 2: review fixes Apply the findings of the two walk-throughs and the coverage check to the 1.0 design spec and the Tiny 2 Lite field ledger: owner route and custody, unsupported-device errors and CLI error codes, hand-over events and rev, hello deadline, identity attempts while asleep, the restart reply, levels per value and bound, the check script's lag and asleep rows, the process after 1.0, and the table of fields without backing brought in line with the ledger. Public text reworded where it went beyond the evidence policy. New choices are rows 68-81 of the changes table. Co-Authored-By: Claude Opus 5.5 --- docs/field-ledger.md | 240 ++-- .../2026-11-01-obsbot4linux-1.0-design.md | 1209 ++++++++++------- 2 files changed, 840 insertions(+), 609 deletions(-) diff --git a/docs/field-ledger.md b/docs/field-ledger.md index 8b1d531..1c98b4e 100644 --- a/docs/field-ledger.md +++ b/docs/field-ledger.md @@ -6,19 +6,22 @@ and per action of spec §4.4. The rules for this file are in spec §8.5; the mod spec §8.8. Nothing here was sent to a camera to write it. **Wire notation.** `short XX LL `: short command on extension-unit selector 6 (command, length, -value). `framed 0xNNNN`: framed command to receiver 4 unless `rcv` names another; payloads are -little-endian. `query 0xNNNN`: framed query and its answer. `status 0xNN`: byte at that offset of -the status block (selector 6 read). `0x0104 byte N`: byte N of the answer to query `0x0104`. -`UVC CT` / `UVC PU`: standard UVC camera-terminal and processing-unit controls, through V4L2. -On the wire, pitch is positive down and yaw positive to the camera's left; the model converts to -the units of spec §4.2. Preset layout: 24 bytes, `u32 lead, f32 yaw, f32 pitch, f32 0, f32 zoom, -f32 last` (`last` −1000 for presets and views, 0 for start pose and returns). +value). `framed 0xNNNN`: framed command on selector 2 (written), to receiver 4 unless `rcv` names +another; payloads are little-endian. `query 0xNNNN`: framed query on selector 2 and its answer, +read from selector 2. `status 0xNN`: byte at that offset of the status block (selector 6 read). +`selector 0x0c` and `selector 0x0d`: the preset list and its entries. `0x0104 byte N`: byte N +of the answer to query `0x0104`. `UVC CT` / `UVC PU`: standard UVC camera-terminal and +processing-unit controls, through V4L2. On the wire, pitch is positive down and yaw positive to +the camera's left; the model converts to the units of spec §4.2. Preset layout: 24 bytes, +`u32 lead, f32 yaw, f32 pitch, f32 0, f32 zoom, f32 last` (`last` −1000 for presets and views, +0 for start pose and returns). -**Evidence** (the levels of spec §1). `capture N`: recorded from the vendor app in capture N. -`measured `: measured on the camera on that date; `#N` names the public issue or pull -request that records it. `none`: no backing today. `daemon`: not a camera value. Column §9.3: the -row of spec §9.3 that the spec attaches to the field; where that row and the evidence column -disagree, the last section lists it. +**Evidence** (the levels defined in the spec's preamble). `capture N`: recorded from the vendor +app in capture N. `measured `: measured on the camera on that date; `#N` names the public +issue or pull request that records it. `none`: no backing today. `daemon`: not a camera value. +Levels hold per field, value and bound (spec §8.5): where values or bounds of one row have +different evidence, the evidence column names each. Column §9.3: the row of spec §9.3 for the +field, value or bound that has no backing today. **Asleep** (spec §5.7): `ignored`, `accepted` or `unknown`; `–` for read-only leaves. "Not read asleep": the readback is not sent while the camera sleeps, so a write stays pending until wake. @@ -28,103 +31,102 @@ follows 1–3 s after the write, #81; the target-lock status about 1 s). Per-lea ## Identity (spec §5.5, §8.8) -| Value | Query | Evidence | -| ---------- | ----------------------------------------------------- | ----------------------------- | -| `serial` | query `0x18c8` rcv `0x0d`: 14 ASCII characters | capture 01; Linux read: CAM-1 | -| `firmware` | query `0x0408` rcv `0x0d`: u32, shown byte 3.2.1.0 | capture 01 | -| `model` | query `0x1948` rcv `0x0d`: product and platform codes | capture 01 | +| Value | Query | Evidence | +| ---------- | -------------------------------------------------- | ----------------------------- | +| `serial` | query `0x18c8` rcv `0x0d`: 14 ASCII characters | capture 01; Linux read: CAM-1 | +| `firmware` | query `0x0408` rcv `0x0d`: u32, shown byte 3.2.1.0 | capture 01 | ## State (spec §4.3) -| Path | Write | Readback | Evidence | §9.3 | Asleep | settle_ms | -| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ---------------- | ----------------------------- | --------- | -| `available` | – | sysfs enumeration (spec §5.3) | daemon | | – | – | -| `pending` | – | spec §4.1 rule 4 | daemon | | – | – | -| `unread` | – | spec §4.2 | daemon | | – | – | -| `asleep` | actions `sleep`, `wake` | status `0x02`: 0 awake, 1 asleep | capture 13; measured 2026-10-24 | | – | – | -| `on_air.active` | – | buffer request on the video node: busy while another reader holds the stream | measured 2026-10-25 | | – | – | -| `on_air.apps` | – | names of processes that hold the node open, from `/proc`, best effort | measured 2026-10-25 | | – | – | -| `gimbal.pan` (live `pose`) | – (actions) | query `0x0043` rcv 3: bytes 10–11, i16 yaw, 0.01° | measured 2026-10-31 (#67) | | – | – | -| `gimbal.tilt` (live `pose`) | – (actions) | query `0x0043` rcv 3: bytes 8–9, i16 pitch, 0.01° | measured 2026-10-31 (#67) | | – | – | -| `gimbal.invert` | framed `0x3b84`, u8 0/1 | `0x0104` byte 11 | capture 05, 17 | | unknown | 3000 | -| `zoom.factor` | UVC CT zoom absolute, 0–100 for 1.0–4.0 | query `0x6804`, f32 (V4L2 keeps the last written value) | capture 04, 11; measured 2026-10-24 | | unknown (CAM-3) | 3000 | -| `zoom.field_of_view` | short `04 01 `: 0 wide, 1 medium, 2 narrow | status `0x11`; 3 = `custom` | capture 09; measured 2026-10-24 | | ignored (measured 2026-10-24) | 3000 | -| `tracking.mode` | short `16 02 `: static `00 00`, group `01 00`, normal `02 00`, upper_body `02 01`, close_up `02 02`, headless `02 03`, lower_body `02 04`, hand `03 00` | status `0x18`, `0x1c` | capture 11 | | unknown | 3000 | -| `tracking.profile` | framed `0x0cc4`, u8: 0 standard, 1 headroom, 2 sport | status `0x24` | capture 11 (0, 2); measured 2026-10-24 (0, 2), 2026-10-31 (#98: 1) | U-2 (`headroom`) | unknown | 3000 | -| `tracking.speed` | framed `0x0944`, u8 0–5: lazy, slow, normal, fast, very_fast, auto | query `0x0984`, u8 | measured 2026-10-31 (#98: read back; effect not observed) | U-1 | unknown | 3000 | -| `tracking.target.detected` | – | query `0x01c4`, sent only while `tracking.mode` is not `static` | measured 2026-10-25 | | – | – | -| `tracking.target.box` (live `target`) | – | query `0x01c4`: f32 left, top, right, bottom, picture fractions | measured 2026-10-25; layout #71 | | – | – | -| `framing.auto_zoom` | framed `0x0a44`, u8 0/1 | query `0x0a84`, u8 | measured 2026-10-31 (#98: read back; effect not observed) | U-3 | unknown | 3000 | -| `framing.size` | framed `0x0ac4`, u8 0–5: long_shot … big_close_up | query `0x0b04`, u8 | measured 2026-10-31 (#98: read back; effect not observed) | U-3 | unknown | 3000 | -| `framing.zoom_speed` | framed `0x0b44`, u8: 0 slow, 1 medium, 2 fast | query `0x0b84`, u8; 10, 30 or 50 while never set | measured 2026-10-31 (#98: read back; effect not observed) | U-3 | unknown | 3000 | -| `framing.offset.x` | framed `0x0bc4`, f32 −0.16..0.16 | query `0x0c04`, f32 | measured 2026-10-31 (#98: read back; bounds and direction not checked) | U-4 | unknown | 3000 | -| `framing.offset.y` | framed `0x0c44`, f32 −0.16..0.16 | query `0x0c84`, f32 | measured 2026-10-31 (#98: read back; bounds and direction not checked) | U-4 | unknown | 3000 | -| `framing.part` | framed `0x1584`, u8: 0 normal, 1 head_removal, 2 lower_body | query `0x15c4`, u8 | measured 2026-10-31 (#98: read back; effect not observed) | U-3 | unknown | 3000 | -| `gestures.target_lock` | framed `0x30c4`, u8 0/1 | `0x0104` byte 3; status `0x01` follows about 1 s later | capture 04; measured 2026-10-24 | | unknown | 3000 | -| `gestures.zoom` | framed `0x3144`, u8 0/1 | `0x0104` byte 4 | capture 04; measured 2026-10-24 | | unknown | 3000 | -| `gestures.zoom_ratio` | framed `0x3244`, f32 1.0–4.0 | `0x0104` byte 7, tenths | capture 04; measured 2026-10-24 | | unknown | 3000 | -| `gestures.dynamic_zoom` | framed `0x3344`, u8 0/1 | `0x0104` byte 5 | capture 04; measured 2026-10-24 | | unknown | 3000 | -| `gestures.direction_change` | framed `0x33c4`, u8 0/1 | `0x0104` byte 6 | capture 04; measured 2026-10-24 | | unknown | 3000 | -| `zone.enabled` | framed `0x08c4`, u8 0/1 | `0x0104` byte 10 | capture 11; measured 2026-10-31 (#81) | | unknown | 3000 | -| `zone.follow` | framed `0x4e04`, u8 0/1 | query `0x4e44`, u8 | capture 11, 18; measured 2026-10-25 | | unknown | 3000 | -| `zone.area.pan` | framed `0x4c04` (min), then `0x4c84` (max), f32 yaw | queries `0x4c44`, `0x4cc4`, f32 | capture 11; measured 2026-10-25, 2026-10-27 (#58) | U-8 (order) | unknown | 3000 | -| `zone.area.tilt` | framed `0x4d04` (min), then `0x4d84` (max), f32 pitch: the bounds swap and change sign | queries `0x4d44`, `0x4dc4`, f32 | capture 11; measured 2026-10-25, 2026-10-27 (#58) | U-8 (order) | unknown | 3000 | -| `zone.return` | – (actions) | query `0x4ec4`, preset layout; the lead is always 0, so never `null` | capture 11, 18; measured 2026-10-25 | | – | – | -| `hand.side` | framed `0x2244`, u8: 1 left, 0 right | `0x0104` byte 2 | capture 11, 17 | | unknown | 3000 | -| `hand.area.pan` | framed `0x2044` (min), then `0x20c4` (max), f32 yaw | query `0x2004`: f32 0 and 1 | capture 11 | U-8 (order) | unknown | 3000 | -| `hand.area.tilt` | framed `0x2144` (min), then `0x21c4` (max), f32 pitch: the bounds swap and change sign | query `0x2004`: f32 2 and 3 | capture 11 | U-8 (order) | unknown | 3000 | -| `hand.axes.pan` | framed `0x2344`, u8 0/1 | query `0x2004` byte 22 | none | U-5 | unknown | 3000 | -| `hand.axes.tilt` | framed `0x23c4`, u8 0/1 | query `0x2004` byte 23 | none | U-5 | unknown | 3000 | -| `hand.return_after` | framed `0x2444`, f32 seconds | query `0x2484`, f32 | none | U-6 | unknown | 3000 | -| `hand.move_gimbal` | framed `0x26c4`, u8 0/1; the camera does not store it | none | capture 11 | | unknown | – | -| `hand.return` | – (actions) | query `0x2504`, preset layout; lead −1 = none, shown as `null` | capture 11; measured 2026-10-25 | | – | – | -| `presets` | – (actions) | selector `0x0c` read (count, slots) and written back to rewind, then selector `0x0d` once per preset: slot, pitch, yaw, zoom, base64 name | measured 2026-10-31 (#104) | | – | – | -| `views` | – (actions) | query `0x1344` (ids; an answer without data = none), then per id `0x1504` (name) and `0x1444` (pose) | capture 11, 18; measured 2026-10-31 (#100: empty list) | U-14 | – | – | -| `exposure.mode` | UVC CT auto-exposure mode: `manual` or automatic | V4L2 `auto_exposure` | measured 2026-10-31 (#97) | | unknown | 3000 | -| `exposure.region` | short `03 01 `: 0 global, 1 face | status `0x07` | capture 12; measured 2026-10-24 | | unknown | 3000 | -| `exposure.time` (live `auto`) | UVC CT exposure time absolute, 100 µs units; requires `exposure.mode=manual`; the camera takes the step at or below | V4L2 | measured 2026-10-31 (#97) | | unknown | 3000 | -| `exposure.gain` (live `auto`) | UVC PU gain, 1–64 | V4L2 | capture 12 | | unknown | 3000 | -| `exposure.compensation` | UVC PU backlight compensation, raw 0–18, default 9 | V4L2 | capture 08b, 12 | U-20 (EV scale) | unknown | 3000 | -| `exposure.hdr` | short `01 01 ` | status `0x06` | capture 12; measured 2026-10-24 | | unknown | 3000 | -| `exposure.anti_flicker` | UVC PU power line frequency: 0 off, 1 50hz, 2 60hz | status `0x03`; 3 = `auto` | capture 12; measured 2026-10-24 (0–2); value `auto`: none | | unknown | 3000 | -| `image.brightness` | UVC PU brightness | V4L2 | measured 2026-10-24 | | unknown | 3000 | -| `image.contrast` | UVC PU contrast | V4L2 | capture 08b, 12; measured 2026-10-24 | | unknown | 3000 | -| `image.saturation` | UVC PU saturation | V4L2 | capture 08b, 12; measured 2026-10-24 | | unknown | 3000 | -| `image.hue` | UVC PU hue | V4L2 | capture 08b, 12 | | unknown | 3000 | -| `image.sharpness` | UVC PU sharpness | V4L2 | capture 08b, 12; measured 2026-10-24 | | unknown | 3000 | -| `image.white_balance.auto` | UVC PU white balance temperature auto | V4L2 | capture 08b, 12 | | unknown | 3000 | -| `image.white_balance.temperature` (live `auto`) | UVC PU white balance temperature, K; requires `image.white_balance.auto=false` | V4L2 | capture 08b, 12 | | unknown | 3000 | -| `image.mirror` | short `14 01 ` | status `0x13` | capture 05; measured 2026-10-24 | | unknown | 3000 | -| `image.frame_rate` | – | status `0x1f`: 30 or 60 | measured 2026-10-24 | | – | – | -| `focus.mode` | framed `0x3442` rcv 2, u32: 1 continuous, 2 single, 3 manual; UVC CT focus auto sets the same continuous bit | query `0x3642` rcv 2, word 0; status `0x0e` is 1 only for continuous | capture 12 (focus auto); measured 2026-10-31 (#97) | U-15 | unknown; not read asleep | 3000 | -| `focus.region` | framed `0x3602` rcv 2, u32: 0 global, 1 face | status `0x0d` | capture 12; measured 2026-10-24 | | unknown | 3000 | -| `focus.position` | UVC CT focus absolute, 0–100; requires `focus.mode=manual` | query `0x3642` rcv 2, word 1 (status `0x0f` too) | capture 12; measured 2026-10-31 (#97: read back; #99: no visible effect) | U-22 | unknown; not read asleep | 3000 | -| `audio.enabled` | short `1c 01 `; the camera restarts on both values, about 18 s | the camera's USB audio interface present in sysfs | capture 14; measured 2026-10-24 (restart; the interface goes and returns) | U-23 (readback) | unknown | 3000 | -| `audio.volume` | framed `0x8102` rcv 2, i16 0–100 | query `0x80c2` rcv 2, i16 | measured 2026-10-31 (#97: 30, 55, 85 read back) | U-17 | unknown; not read asleep | 3000 | -| `audio.noise_reduction` | short `0a 01 `: 0 off, 1 weak, 2 medium, 3 strong | status `0x08` | capture 06; measured 2026-10-24 | | unknown | 3000 | -| `audio.auto_gain` | short `17 01 ` | status `0x19` | capture 06; measured 2026-10-24 | | unknown | 3000 | -| `audio.in_sleep` | short `13 01 ` | status `0x10` | capture 06; measured 2026-10-24 | | unknown | 3000 | -| `led.brightness` | short `1a 01 `, 0–3 | status `0x21` | capture 07; measured 2026-10-24 | | unknown | 3000 | -| `led.blink` | short `18 01 ` | none | capture 11, 18; measured 2026-10-25 (the LED blinks) | | unknown | – | -| `sleep.auto.enabled` | short `0b 02 `, one word with `sleep.auto.seconds`: negative = off, the seconds are kept | status `0x0a`, i16 | capture 02; measured 2026-10-24 | | unknown | 3000 | -| `sleep.auto.seconds` | the same word, 1–32767 | status `0x0a`, i16 | capture 02; measured 2026-10-24 | | unknown | 3000 | -| `sleep.background` | short `0e 02 00 `: 0 black, 1 image | status `0x1b`; 3 = `own_image` | capture 03, 18 (`own_image`); measured 2026-10-24 | | unknown | 3000 | -| `sleep.background_mirror` | short `0e 02 02 ` | status `0x1d` | capture 03; measured 2026-10-24 | | unknown | 3000 | -| `sleep.wake_zoom` | short `15 02 64 `, v 0–100 for 1.0–4.0 | status `0x16`, u16 | measured 2026-10-31 (#97: read back; #102: no effect) | U-21 | unknown | 3000 | -| `power_on.mode` | short `19 01 `, v = m << 4 \| n of the `16 02` pair | status `0x20` | measured 2026-10-31 (#97: read back; #102: applied at power-on) | | unknown | 3000 | -| `power_on.start.source` | – (actions) | query `0x3884`, lead: 3 = `own`, 0–2 = `preset_` | capture 08; measured 2026-10-25 (`own`); `preset_`: none | | – | – | -| `power_on.start.pose` | – (actions) | query `0x3884`, preset layout | capture 08; measured 2026-10-25 | | – | – | -| `power_on.start.mode` | framed `0x3e44`: the block read by query `0x3e84` with byte 0 changed: static `ff`, normal `00`, upper_body `01`, headless `03`, group `09`, unset `fe` | query `0x3e84` byte 0; also close_up `02`, lower_body `04`, hand `07` or `08` | capture 08 (normal, unset) | U-18 (others) | unknown | 3000 | -| `power_on.settings.tracking_speed` | – | query `0x3e84` byte 1; `ff` = not set | capture 08b (one value) | U-19 | – | – | -| `power_on.settings.autofocus` | – | query `0x3e84` byte 2; `ff` = not set | capture 08b (one value) | U-19 | – | – | -| `power_on.settings.auto_exposure` | – | query `0x3e84` byte 6; `ff` = not set | capture 08b (one value) | U-19 | – | – | -| `power_on.settings.exposure_compensation` | – | query `0x3e84` byte 8 | capture 08b (one value) | U-19 | – | – | -| `power_on.settings.white_balance_mode` | – | query `0x3e84` byte 13; `ff` = not set | capture 08b (one value) | U-19 | – | – | -| `power_on.settings.white_balance_temperature` | – | query `0x3e84` bytes 14–15, u16 K, with mode 5 | capture 08b (one value) | U-19 | – | – | -| `power_on.settings.contrast` | – | query `0x3e84` byte 20; `ff` = not set | capture 08b (one value) | U-19 | – | – | -| `power_on.settings.saturation` | – | query `0x3e84` byte 21; `ff` = not set | capture 08b (one value) | U-19 | – | – | -| `power_on.settings.sharpness` | – | query `0x3e84` byte 22; `ff` = not set | capture 08b (one value) | U-19 | – | – | -| `power_on.settings.hue` | – | query `0x3e84` byte 23; `ff` = not set | capture 08b (one value) | U-19 | – | – | +| Path | Write | Readback | Evidence | §9.3 | Asleep | settle_ms | +| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------- | ----------------------------- | --------- | +| `available` | – | sysfs enumeration (spec §5.3) | daemon | | – | – | +| `pending` | – | spec §4.1 rule 4 | daemon | | – | – | +| `unread` | – | spec §4.2 | daemon | | – | – | +| `asleep` | actions `sleep`, `wake` | status `0x02`: 0 awake, 1 asleep | capture 13; measured 2026-10-24 | | – | – | +| `on_air.active` | – | buffer request on the video node: busy while another reader holds the stream | measured 2026-10-25 | | – | – | +| `on_air.apps` | – | names of processes that hold the node open, from `/proc`, best effort | measured 2026-10-25 | | – | – | +| `gimbal.pan` (live `pose`) | – (actions) | query `0x0043` rcv 3: bytes 10–11, i16 yaw, 0.01° | measured 2026-10-31 (#67) | | – | – | +| `gimbal.tilt` (live `pose`) | – (actions) | query `0x0043` rcv 3: bytes 8–9, i16 pitch, 0.01° | measured 2026-10-31 (#67) | | – | – | +| `gimbal.invert` | framed `0x3b84`, u8 0/1 | `0x0104` byte 11 | capture 05, 17 | | unknown | 3000 | +| `zoom.factor` | UVC CT zoom absolute, 0–100 for 1.0–4.0 | query `0x6804`, f32 (V4L2 keeps the last written value) | capture 04, 11; measured 2026-10-24 | | unknown (CAM-3) | 3000 | +| `zoom.field_of_view` | short `04 01 `: 0 wide, 1 medium, 2 narrow | status `0x11`; 3 = `custom` | capture 09; measured 2026-10-24 | | ignored (measured 2026-10-24) | 3000 | +| `tracking.mode` | short `16 02 `: static `00 00`, group `01 00`, normal `02 00`, upper_body `02 01`, close_up `02 02`, headless `02 03`, lower_body `02 04`, hand `03 00` | status `0x18`, `0x1c` | capture 11 | | unknown | 3000 | +| `tracking.profile` | framed `0x0cc4`, u8: 0 standard, 1 headroom, 2 sport | status `0x24` | capture 11 (0, 2); measured 2026-10-24 (0, 2), 2026-10-31 (#98: 1) | | unknown | 3000 | +| `tracking.speed` | framed `0x0944`, u8 0–5: lazy, slow, normal, fast, very_fast, auto | query `0x0984`, u8 | measured 2026-10-31 (#98: read back; effect not observed) | | unknown | 3000 | +| `tracking.target.detected` | – | query `0x01c4`, sent only while `tracking.mode` is not `static` | measured 2026-10-25 | | – | – | +| `tracking.target.box` (live `target`) | – | query `0x01c4`: f32 left, top, right, bottom, picture fractions | measured 2026-10-25; layout #71 | | – | – | +| `framing.auto_zoom` | framed `0x0a44`, u8 0/1 | query `0x0a84`, u8 | measured 2026-10-31 (#98: read back; effect not observed) | | unknown | 3000 | +| `framing.size` | framed `0x0ac4`, u8 0–5: long_shot … big_close_up | query `0x0b04`, u8 | measured 2026-10-31 (#98: read back; effect not observed) | | unknown | 3000 | +| `framing.zoom_speed` | framed `0x0b44`, u8: 0 slow, 1 medium, 2 fast | query `0x0b84`, u8; 10, 30 or 50 while never set | measured 2026-10-31 (#98: read back; effect not observed) | | unknown | 3000 | +| `framing.offset.x` | framed `0x0bc4`, f32 −0.16..0.16 | query `0x0c04`, f32 | measured 2026-10-31 (#98: read back; bounds and direction not checked) | U-4 (bounds, direction) | unknown | 3000 | +| `framing.offset.y` | framed `0x0c44`, f32 −0.16..0.16 | query `0x0c84`, f32 | measured 2026-10-31 (#98: read back; bounds and direction not checked) | U-4 (bounds, direction) | unknown | 3000 | +| `framing.part` | framed `0x1584`, u8: 0 normal, 1 head_removal, 2 lower_body | query `0x15c4`, u8 | measured 2026-10-31 (#98: read back; effect not observed) | | unknown | 3000 | +| `gestures.target_lock` | framed `0x30c4`, u8 0/1 | `0x0104` byte 3; status `0x01` follows about 1 s later | capture 04; measured 2026-10-24 | | unknown | 3000 | +| `gestures.zoom` | framed `0x3144`, u8 0/1 | `0x0104` byte 4 | capture 04; measured 2026-10-24 | | unknown | 3000 | +| `gestures.zoom_ratio` | framed `0x3244`, f32 1.0–4.0 | `0x0104` byte 7, tenths | capture 04; measured 2026-10-24 | | unknown | 3000 | +| `gestures.dynamic_zoom` | framed `0x3344`, u8 0/1 | `0x0104` byte 5 | capture 04; measured 2026-10-24 | | unknown | 3000 | +| `gestures.direction_change` | framed `0x33c4`, u8 0/1 | `0x0104` byte 6 | capture 04; measured 2026-10-24 | | unknown | 3000 | +| `zone.enabled` | framed `0x08c4`, u8 0/1 | `0x0104` byte 10 | capture 11; measured 2026-10-31 (#81) | | unknown | 3000 | +| `zone.follow` | framed `0x4e04`, u8 0/1 | query `0x4e44`, u8 | capture 11, 18; measured 2026-10-25 | | unknown | 3000 | +| `zone.area.pan` | framed `0x4c04` (min), then `0x4c84` (max), f32 yaw | queries `0x4c44`, `0x4cc4`, f32 | capture 11; measured 2026-10-25, 2026-10-27 (#58) | U-8 (order) | unknown | 3000 | +| `zone.area.tilt` | framed `0x4d04` (min), then `0x4d84` (max), f32 pitch: the bounds swap and change sign | queries `0x4d44`, `0x4dc4`, f32 | capture 11; measured 2026-10-25, 2026-10-27 (#58) | U-8 (order) | unknown | 3000 | +| `zone.return` | – (actions) | query `0x4ec4`, preset layout; the lead is always 0, so never `null` | capture 11, 18; measured 2026-10-25 | | – | – | +| `hand.side` | framed `0x2244`, u8: 1 left, 0 right | `0x0104` byte 2 | capture 11, 17 | | unknown | 3000 | +| `hand.area.pan` | framed `0x2044` (min), then `0x20c4` (max), f32 yaw | query `0x2004`: f32 0 and 1 | capture 11 | U-8 (order) | unknown | 3000 | +| `hand.area.tilt` | framed `0x2144` (min), then `0x21c4` (max), f32 pitch: the bounds swap and change sign | query `0x2004`: f32 2 and 3 | capture 11 | U-8 (order) | unknown | 3000 | +| `hand.axes.pan` | framed `0x2344`, u8 0/1 | query `0x2004` byte 22 | none | U-5 | unknown | 3000 | +| `hand.axes.tilt` | framed `0x23c4`, u8 0/1 | query `0x2004` byte 23 | none | U-5 | unknown | 3000 | +| `hand.return_after` | framed `0x2444`, f32 seconds | query `0x2484`, f32 | none | U-6 | unknown | 3000 | +| `hand.move_gimbal` | framed `0x26c4`, u8 0/1; the camera does not store it | none | capture 11 | | unknown | – | +| `hand.return` | – (actions) | query `0x2504`, preset layout; lead −1 = none, shown as `null` | capture 11; measured 2026-10-25 | | – | – | +| `presets` | – (actions) | selector `0x0c` read (count, slots) and written back to rewind, then selector `0x0d` once per preset: slot, pitch, yaw, zoom, base64 name | measured 2026-10-31 (#104) | | – | – | +| `views` | – (actions) | query `0x1344` (ids; an answer without data = none), then per id `0x1504` (name) and `0x1444` (pose) | capture 11, 18; measured 2026-10-31 (#100: empty list) | | – | – | +| `exposure.mode` | UVC CT auto-exposure mode: `manual` or automatic | V4L2 `auto_exposure` | measured 2026-10-31 (#97) | | unknown | 3000 | +| `exposure.region` | short `03 01 `: 0 global, 1 face | status `0x07` | capture 12; measured 2026-10-24 | | unknown | 3000 | +| `exposure.time` (live `auto`) | UVC CT exposure time absolute, 100 µs units; requires `exposure.mode=manual`; the camera takes the step at or below | V4L2 | measured 2026-10-31 (#97) | | unknown | 3000 | +| `exposure.gain` (live `auto`) | UVC PU gain, 1–64 | V4L2 | capture 12 | | unknown | 3000 | +| `exposure.compensation` | UVC PU backlight compensation, raw 0–18, default 9 | V4L2 | capture 08b, 12 | U-20 (EV scale) | unknown | 3000 | +| `exposure.hdr` | short `01 01 ` | status `0x06` | capture 12; measured 2026-10-24 | | unknown | 3000 | +| `exposure.anti_flicker` | UVC PU power line frequency: 0 off, 1 50hz, 2 60hz | status `0x03`; 3 = `auto` | capture 12; measured 2026-10-24 (0–2); value `auto`: none | U-25 (`auto`) | unknown | 3000 | +| `image.brightness` | UVC PU brightness | V4L2 | measured 2026-10-24 | | unknown | 3000 | +| `image.contrast` | UVC PU contrast | V4L2 | capture 08b, 12; measured 2026-10-24 | | unknown | 3000 | +| `image.saturation` | UVC PU saturation | V4L2 | capture 08b, 12; measured 2026-10-24 | | unknown | 3000 | +| `image.hue` | UVC PU hue | V4L2 | capture 08b, 12 | | unknown | 3000 | +| `image.sharpness` | UVC PU sharpness | V4L2 | capture 08b, 12; measured 2026-10-24 | | unknown | 3000 | +| `image.white_balance.auto` | UVC PU white balance temperature auto | V4L2 | capture 08b, 12 | | unknown | 3000 | +| `image.white_balance.temperature` (live `auto`) | UVC PU white balance temperature, K; requires `image.white_balance.auto=false` | V4L2 | capture 08b, 12 | | unknown | 3000 | +| `image.mirror` | short `14 01 ` | status `0x13` | capture 05; measured 2026-10-24 | | unknown | 3000 | +| `image.frame_rate` | – | status `0x1f`: 30 or 60 | measured 2026-10-24 | | – | – | +| `focus.mode` | framed `0x3442` rcv 2, u32: 1 continuous, 2 single, 3 manual; UVC CT focus auto sets the same continuous bit | query `0x3642` rcv 2, word 0; status `0x0e` is 1 only for continuous | capture 12 (focus auto); measured 2026-10-31 (#97) | | unknown; not read asleep | 3000 | +| `focus.region` | framed `0x3602` rcv 2, u32: 0 global, 1 face | status `0x0d` | capture 12; measured 2026-10-24 | | unknown | 3000 | +| `focus.position` | UVC CT focus absolute, 0–100; requires `focus.mode=manual` | query `0x3642` rcv 2, word 1 (status `0x0f` too) | capture 12; measured 2026-10-31 (#97: read back; #99: no visible effect) | | unknown; not read asleep | 3000 | +| `audio.enabled` | short `1c 01 `; the camera restarts on both values, about 18 s | the camera's USB audio interface present in sysfs | capture 14; measured 2026-10-24 (restart; the interface goes and returns) | U-23 (readback) | unknown | 3000 | +| `audio.volume` | framed `0x8102` rcv 2, i16 0–100 | query `0x80c2` rcv 2, i16 | measured 2026-10-31 (#97: 30, 55, 85 read back) | | unknown; not read asleep | 3000 | +| `audio.noise_reduction` | short `0a 01 `: 0 off, 1 weak, 2 medium, 3 strong | status `0x08` | capture 06; measured 2026-10-24 | | unknown | 3000 | +| `audio.auto_gain` | short `17 01 ` | status `0x19` | capture 06; measured 2026-10-24 | | unknown | 3000 | +| `audio.in_sleep` | short `13 01 ` | status `0x10` | capture 06; measured 2026-10-24 | | unknown | 3000 | +| `led.brightness` | short `1a 01 `, 0–3 | status `0x21` | capture 07; measured 2026-10-24 | | unknown | 3000 | +| `led.blink` | short `18 01 ` | none | capture 11, 18; measured 2026-10-25 (the LED blinks) | | unknown | – | +| `sleep.auto.enabled` | short `0b 02 `, one word with `sleep.auto.seconds`: negative = off, the seconds are kept | status `0x0a`, i16 | capture 02; measured 2026-10-24 | | unknown | 3000 | +| `sleep.auto.seconds` | the same word, 1–32767 | status `0x0a`, i16 | capture 02; measured 2026-10-24 | | unknown | 3000 | +| `sleep.background` | short `0e 02 00 `: 0 black, 1 image | status `0x1b`; 3 = `own_image` | capture 03, 18 (`own_image`); measured 2026-10-24 | | unknown | 3000 | +| `sleep.background_mirror` | short `0e 02 02 ` | status `0x1d` | capture 03; measured 2026-10-24 | | unknown | 3000 | +| `sleep.wake_zoom` | short `15 02 64 `, v 0–100 for 1.0–4.0 | status `0x16`, u16 | measured 2026-10-31 (#97: read back; #102: no effect) | | unknown | 3000 | +| `power_on.mode` | short `19 01 `, v = m << 4 \| n of the `16 02` pair | status `0x20` | measured 2026-10-31 (#97: read back; #102: applied at power-on) | | unknown | 3000 | +| `power_on.start.source` | – (actions) | query `0x3884`, lead: 3 = `own`, 0–2 = `preset_` | capture 08; measured 2026-10-25 (`own`); `preset_`: none | U-26 (`preset_`) | – | – | +| `power_on.start.pose` | – (actions) | query `0x3884`, preset layout | capture 08; measured 2026-10-25 | | – | – | +| `power_on.start.mode` | framed `0x3e44`: the block read by query `0x3e84` with byte 0 changed: static `ff`, normal `00`, upper_body `01`, headless `03`, group `09`, unset `fe` | query `0x3e84` byte 0; also close_up `02`, lower_body `04`, hand `07` or `08` | capture 08 (normal, unset) | U-18 (others) | unknown | 3000 | +| `power_on.settings.tracking_speed` | – | query `0x3e84` byte 1; `ff` = not set | capture 08b (one value) | U-19 (other values) | – | – | +| `power_on.settings.autofocus` | – | query `0x3e84` byte 2; `ff` = not set | capture 08b (one value) | U-19 (other values) | – | – | +| `power_on.settings.auto_exposure` | – | query `0x3e84` byte 6; `ff` = not set | capture 08b (one value) | U-19 (other values) | – | – | +| `power_on.settings.exposure_compensation` | – | query `0x3e84` byte 8 | capture 08b (one value) | U-19 (other values) | – | – | +| `power_on.settings.white_balance_mode` | – | query `0x3e84` byte 13; `ff` = not set | capture 08b (one value) | U-19 (other values) | – | – | +| `power_on.settings.white_balance_temperature` | – | query `0x3e84` bytes 14–15, u16 K, with mode 5 | capture 08b (one value) | U-19 (other values) | – | – | +| `power_on.settings.contrast` | – | query `0x3e84` byte 20; `ff` = not set | capture 08b (one value) | U-19 (other values) | – | – | +| `power_on.settings.saturation` | – | query `0x3e84` byte 21; `ff` = not set | capture 08b (one value) | U-19 (other values) | – | – | +| `power_on.settings.sharpness` | – | query `0x3e84` byte 22; `ff` = not set | capture 08b (one value) | U-19 (other values) | – | – | +| `power_on.settings.hue` | – | query `0x3e84` byte 23; `ff` = not set | capture 08b (one value) | U-19 (other values) | – | – | ## Actions (spec §4.4) @@ -134,16 +136,16 @@ 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.stop` | framed `0x6704`, no data | the live pose stops changing | measured 2026-10-31 (#98) | U-10 | 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) | | `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) | U-11 | 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.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 | | `view.save` | live pose unless `pose` is given; `views` read for a free id; framed `0x1384`: preset layout with the id; framed `0x1544` 100 ms later: u32 id, ASCII name | `views`; result `{id}` | capture 11 | | ignored (CAM-22) | -| `view.go` | framed `0x1604`: u32 id | live pose, `zoom.factor` | measured 2026-10-31 (#100) | U-13 | unknown | +| `view.go` | framed `0x1604`: u32 id | live pose, `zoom.factor` | measured 2026-10-31 (#100) | | unknown | | `view.rename` | framed `0x1544`: u32 id, ASCII name | `views` | capture 11 | | unknown | | `view.delete` | framed `0x13c4`: u32 id | `views` | capture 11 | | unknown | | `zone.return_here` | live pose; framed `0x4e84`: preset layout, lead 0 | `zone.return` | capture 11; measured 2026-10-25 | | ignored (CAM-22) | @@ -156,25 +158,7 @@ follows 1–3 s after the write, #81; the target-lock status about 1 s). Per-lea | `power_on.set_pose` | framed `0x3ec4`: preset layout, lead 3; framed `0x3e44` 100 ms later: the block read by query `0x3e84`, unchanged | `power_on.start` | capture 08; measured 2026-10-25 (readback) | | unknown | | `power_on.use_current` | live pose; then the frames of `power_on.set_pose` | `power_on.start` | capture 08 | | ignored (CAM-22) | | `power_on.use_preset` | `presets` read for the slot's pose; framed `0x3ec4`: preset layout, lead = slot; framed `0x3e44` as in `power_on.set_pose` | `power_on.start` | capture 10 | | unknown | -| `power_on.go` | short `00 01 01` | the gimbal moves to the start pose; with `power_on.start.mode=unset` tracking stays suspended | measured 2026-10-31 (#79) | U-24 | ignored (measured 2026-10-31, #79) | +| `power_on.go` | short `00 01 01` | the gimbal moves to the start pose; with `power_on.start.mode=unset` tracking stays suspended | measured 2026-10-31 (#79) | | ignored (measured 2026-10-31, #79) | | `sleep` | framed `0xa0c2` rcv 2, u32 1 | `asleep` true | capture 13; measured 2026-10-24 | | unknown | | `wake` | framed `0xa0c2` rcv 2, u32 0 | `asleep` false | capture 13; measured 2026-10-24 | | accepted (capture 13) | | `factory_reset` | framed `0xa802` rcv 2, u8 `01` | the camera restarts and re-enumerates; the presets are deleted | capture 16; measured 2026-10-24 | | unknown (CAM-9) | - -## Open against the spec - -The spec has not taken these ledger results yet. Each line names the spec section to change. - -- §9.3, backed today (write and readback, or the effect, measured): U-1 and U-3 (#98; the effect - on tracking is not observed), U-2 for the readback of `headroom` (#98; its effect is not - observed), U-10 and U-11 (#98), U-13 (#100), U-15 (#97), U-17 (#97), U-21 and U-22 (read back, - #97; no effect, #102 and #99: they stay `no_effect`), U-24 (#79). -- §9.3, narrower than stated: U-4 (values read back, #98; the bounds and the direction are open), - U-14 (the three reads are in capture 11 and 18), U-19 (each leaf is in capture 08b with one - value; the open part is the meaning of other values), U-16 (accepted and read back, #97; the - effect is wrong, #99). -- §9.3, missing: the value `auto` of `exposure.anti_flicker` (status `0x03` = 3 never recorded; - §15 row 50 says measured) and the values `preset_` of `power_on.start.source` (a start - pose taken from a preset was never read back). -- §8.8: `power_on.go` is `ignored` asleep by measurement (#79), and `wake` is `accepted` asleep - (capture 13). 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 3609ff9..5784475 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 @@ -19,6 +19,10 @@ Words used with a fixed meaning: device or a capture can settle it. - **must**, **never**: requirements. Every requirement in this document can be checked by a test, a review of the diff, or a camera-session item (section 14). +- **Thomas**: the maintainer, who owns the repository. **The maintainers**: Thomas and the + people he names in the repository. +- **#N**: an issue or pull request of `github.com/hellwand/obsbot4linux`; + `obsbot4linux-dux#N`: one of the widget's repository. ## 1. Goal @@ -39,9 +43,15 @@ Success criteria (the release gate is section 10.6): envelopes, a generic path → value payload, a JSON schema and one description file per model. 4. Several cameras at once, hotplug, and a device identity that survives re-plugging. -5. **Guarantee A.** A new model needs no change to `obsbot-proto` (types and client library) - or to the CLI. Hand-made UIs (widget, desktop app) keep working for the settings they - know, gate every control on the device description, and need an update only to show +5. **Guarantee A.** A new model whose settings and actions fit the existing setting types, + units and constraint forms (section 4.2) needs no code change in `obsbot-proto` (types + and client library) or in the CLI. Its description file and the completions are + regenerated as data in the model's PR (section 6.2). A model that needs a new type, unit + or constraint form first brings that as an additive contract change (a minor version, + section 12.5); older clients show such a setting read-only as text (4.1 rule 7). + Hand-made UIs (widget, desktop app) gate every control on the device description and + take values, bounds, step, unit and list limits from it, never from constants of their + own. So they keep working for the settings they know and need an update only to show settings that are new to them. 1.0 has no generated UI. 6. A camera with a known OBSBOT vendor id that no model covers is listed as unsupported and receives nothing (section 8.4). @@ -94,6 +104,7 @@ packaging/ arch, deb, rpm, systemd units, completions docs/ protocol-2.md; field-ledger.md (Tiny 2 Lite); field-ledger-.md for each further model scripts/check-model.sh live check of one device against its description (section 9.4) +scripts/regenerate.sh regenerates schema/, descriptions/ and packaging/completions/ ``` Dependency direction: proto ← device ← daemon; proto ← CLI. The `[dependencies]` of the CLI @@ -108,7 +119,8 @@ decided with M5. ### 3.1 Layers inside obsbot-device 1. **transport**: `trait Transport`: extension-unit get, set and length by selector, UVC - control ioctls, and the descriptor facts read from sysfs. One implementation talks to USB + control ioctls, and the descriptor facts read from sysfs (the USB serial string among + them, for a model whose identity source it is). One implementation talks to USB UVC devices. A replay implementation answers from recorded exchanges for tests. 2. **family**: shared building blocks, not a driver: the frame codec (framing, CRC, sequence numbers), answer matching, short-command write, status-block read, UVC helpers, the on-air @@ -188,7 +200,8 @@ further connection is closed at once. | `live` | `{"id","live":[""…]}` | `{}`; replaces the connection's live list | `subscribe` defaults to `true` and `live` to `[]`. The first message on a connection must be -`hello`. A second `hello` is answered `invalid`. A patch is an object of leaf paths; it may +`hello`. Requests that follow `hello` before its reply are kept and handled after the reply. +A second `hello` is answered `invalid`. A patch is an object of leaf paths; it may nest them (`{"zone":{"area":{"pan":…}}}`) or use dotted keys (`{"zone.area.pan":…}`). **Replies:** `{"id":,"ok":{…}}` or `{"id":,"error":{…}}`. @@ -200,40 +213,48 @@ nest them (`{"zone":{"area":{"pan":…}}}`) or use dotted keys (`{"zone.area.pan entry is added, removed or changed (availability, identity hand-over, firmware, video node). -On unplug of a device with an `sn:` id the daemon sends the `devices` event first, then one -`state` event that carries `available:false` and the last known values; no further state -events follow until the device is back. On re-plug the order is the same: `devices`, then -`state`. A `port:` entry is removed on unplug: the `devices` event no longer lists it, and -no state event follows. +When an entry is added (a supported or an unsupported device), the daemon sends the +`devices` event, then one `state` event for that entry. On unplug of a device with an `sn:` +id the daemon sends the `devices` event first, then one `state` event that carries +`available:false` and the last known values; no further state events follow until the +device is back. On re-plug the order is the same: `devices`, then `state`. A `port:` entry +is removed on unplug: the `devices` event no longer lists it, and no state event follows. +The events of an identity hand-over are in section 5.5. **Errors:** `{"id","error":{"code","message","rev"?,"applied"?,"failed"?,"supported"?}}`. `message` is always present (English, for logs). `rev` is present whenever the daemon re-read the device before answering. -| Code | When | Extra fields | -| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | -| `protocol` | the first message is not `hello`, or its protocol is not supported (the connection is closed; `id:null` if there was none) | `supported:[2]` | -| `invalid` | malformed request, no verb or two verbs, wrong value type, a read-only path, a second `hello`, a destructive action without `confirm:true` | – | -| `unknown_device` | no device with this id | – | -| `unsupported` | well-formed, but the path or action is not in this device's description (a typo included) | – | -| `out_of_range` | the value lies outside the description (bounds, `writable`, string length) | – | -| `conflict` | valid, but the device's current state does not allow it: a `requires` condition not met, an empty preset slot, an unknown view id, no free view | – | -| `unavailable` | the device is unplugged | – | -| `asleep` | the camera is asleep and the description marks the setting or action `asleep: ignored` | – | -| `busy` | 32 requests are already queued for the device (the lease of M5 will use it too) | – | -| `camera` | the camera refused or did not answer within the transport timeouts (section 5.8), the device node stayed locked, or the 15 s deadline passed | `rev`, `applied:[paths]`, `failed:""` | -| `internal` | a daemon bug | – | +| Code | When | Extra fields | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `protocol` | the first message is not `hello`, or its protocol is not supported (the connection is closed; `id:null` if there was none) | `supported:[2]` | +| `invalid` | malformed request, no verb or two verbs, wrong value type, a read-only path, a second `hello`, a destructive action without `confirm:true` | – | +| `unknown_device` | no device with this id | – | +| `unsupported` | well-formed, but the path or action is not in this device's description (a typo included) | – | +| `unsupported_device` | a `set` or `do` for a device with `support: unsupported` (section 5.4) | – | +| `out_of_range` | the value lies outside the description (bounds, `writable`, string length) | – | +| `conflict` | valid, but the device's current state does not allow it: a `requires` condition not met, an empty preset slot, an unknown view id, no free view | – | +| `unavailable` | a `set` or `do` for a device that is unplugged | – | +| `asleep` | the camera is asleep and the description marks the setting or action `asleep: ignored` | – | +| `busy` | 32 requests are already queued for the device (the lease of M5 will use it too) | – | +| `camera` | the camera refused or did not answer within the transport timeouts (section 5.8), the device node stayed locked, or the 15 s deadline passed | `rev`, `applied:[paths]`, `failed:""` | +| `internal` | a daemon bug | – | **Ordering, `rev` and `pending` (normative):** -1. `rev` is per device. It only goes up and restarts with the daemon. Every change of a - device's published state (values, `pending`, `unread`, `available`) gets a new `rev`. -2. All requests for one device go through that device's queue and are answered in request - order, with these exceptions: `busy`, `unknown_device` and `unavailable` are answered at - once, and a stop (an action the model marks as a stop; on the Tiny 2 Lite `gimbal.stop` and - `zoom.stop`) is taken before every queued request and answered when the camera accepted - it. Requests for different devices may be answered in any order. Requests may be - pipelined. +1. `rev` is per device id. It is 1 for the first state of a new entry, only goes up, and + restarts with the daemon. Every change of a device's published state (values, `pending`, + `unread`, `available`) gets a new `rev`. The state of an unsupported entry never changes, + so its `rev` stays 1. +2. All requests for a supported, available device go through that device's queue and are + answered in request order, with these exceptions, which the connection's reader thread + answers at once: `busy`, `unknown_device`; every request for an unavailable device (`get` + from its published state, `set` and `do` with `unavailable`); every request for an + unsupported device (`get` from its fixed state, `set` and `do` with + `unsupported_device`). A stop (an action the model marks as a stop; on the Tiny 2 Lite + `gimbal.stop` and `zoom.stop`) is taken before every queued request and answered when + the camera accepted it. Requests for different devices may be answered in any order. + Requests may be pipelined. 3. For a `set` or `do` that changes state, the daemon sends the state event to every subscribed connection that receives it (rule 8), the requester included, **before** the reply. The reply's `rev` is the rev of the first state that reflects the request: that @@ -289,9 +310,13 @@ Socket activation starts the daemon. Requests die with their connection. After a reconnect a client drops every pending request and every own value, sends `hello` and rebuilds from its reply. Revs from two connections are never compared. -**`hello` timing:** the daemon answers `hello` only after its first complete enumeration -(section 5.2). The reply and the start of event delivery are atomic: no state published -after the reply's snapshot is missing from the events that follow. +**`hello` timing:** the daemon answers `hello` after its first enumeration and the first +read attempt of every supported device that enumeration found (section 5.2), and at the +latest 15 s (`REQUEST_DEADLINE`) after the `hello` arrived. A device whose first read +attempt has not ended by then is in the reply with its leaves `null` and listed in +`unread`; its first read publishes a new state later. The reply and the start of event +delivery are atomic: no state published after the reply's snapshot is missing from the +events that follow. ### 4.2 Devices and descriptions @@ -306,7 +331,6 @@ usb = { vendor, product, product_name, bcd_device, path, serial_present, Setting = { type, label, readonly, nullable, readback, asleep, no_effect, values[], writable[] (enum) min, max, step, unit, precision (number, range, pose, point, box) - kind, optical_max (zoom factor only; kind digital|optical|hybrid) max_len, ascii (string) item{ → Setting} (list; read-only) optional (action parameters only) @@ -322,16 +346,28 @@ Device fields: `port:` (section 5.5). A `port:` id is never stored. - `name`: the model's display name plus the last four characters of the serial (`OBSBOT Tiny 2 Lite 0001`); without a serial, the display name plus the USB path; for an - unsupported device, the USB product name plus the USB path. + unsupported device, the USB product name plus the USB path, or `USB :` + plus the USB path when the device has no product string. - `model`: the model key (`tiny2lite`, `fake-a`, `fake-b`); `unknown` for an unsupported device. - `support`: `tested`, `untested` or `unsupported` (section 8.6). It is device-level only. Per-field evidence lives in the model's ledger, not in the protocol. - `serial`, `firmware`: as the camera reported them, or `null`. -- `video_node`: `/dev/videoN` of the device's capture node, or `null`. +- `video_node`: `/dev/videoN` of the device's video node with sysfs index 0, or `null`. A + USB device with several video nodes is one entry. - `usb`: read from sysfs at enumeration, never from the camera. `serial_present` says whether the USB descriptor has a serial string; the string itself is never in `usb`. - `devices --report` prints this object (section 6.2). + `devices --report` prints this object (section 6.2). Formats, as sysfs gives them: + - `vendor`, `product`, `bcd_device`: strings of four lower-case hex digits (`"3564"`). + - `product_name`: the product string, or `null` without one. + - `path`: the USB device name in sysfs (bus, then the port chain: `"3-2.1"`). It stays the + same across re-plugs into the same port and across daemon restarts, and changes when the + device is plugged into another port. The `port:` id uses it. + - `interfaces`: the interface class of each interface, as two lower-case hex digits + (`"0e"` video, `"01"` audio), in interface order. + - `extension_units`: per UVC extension unit, `unit` (integer id), `guid` (lower-case, + `8-4-4-4-12` form) and `controls` (integer, the number of controls the descriptor + declares). - `limits`: the maximum item count of a list setting, where the camera has one (`{"presets":3}`). A list without an entry has no known maximum. @@ -358,10 +394,10 @@ Setting and Action fields: target box, values the camera adjusts itself). - `settle_ms`: the longest time the camera's readback may lag a write of this leaf (rule 4). It is a measured property of the model. -- `warning`: `restart` (the camera restarts after this write or action) or `destructive` - (data on the camera is lost). An action with `warning: destructive` requires the - parameter `confirm: true`; without it the daemon answers `invalid`. Generic UIs ask - before they send either. +- `warning`: a list of one or both of `restart` (the camera restarts after this write or + action) and `destructive` (data on the camera is lost). An action whose `warning` holds + `destructive` requires the parameter `confirm: true`; without it the daemon answers + `invalid`. Generic UIs ask before they send either. Rules: @@ -370,13 +406,19 @@ Rules: - **One meaning per path.** A path means the same function, unit and frame on every model. Models differ in which paths they have and in `values[]`, `writable[]` and bounds. A new function gets a new path or a new enum value. +- **Path dictionary.** `docs/protocol-2.md` lists every path any model uses, with its + function, type, unit and frame (the units and frames rule below). A model PR that adds a + path or an enum value adds its line there; the review checks that a reused path has the + meaning of its line. Naming: lower-case `snake_case` segments joined by `.`, the camera + function first (`zoom`, `tracking`, `exposure`), then the leaf; no model name in a path. - **Absent vs null.** A field the model does not have is absent from state and description. `null` means one of: the camera reports "not set" (only where the description says `nullable`); the leaf was never read (it is in `unread`); or a `readback:false` leaf that was not written since the device last became available. -- **`unread`** is part of every state: the leaf paths whose last read failed. They keep - their last value. Their group is read again at its next tier tick while the camera is - awake. +- **`unread`** is part of every state: the leaf paths that were never read since the device + last became available (`null`; a group not read while asleep included), and those whose + last read failed (they keep their last value). Their group is read again at its next tier + tick while the camera is awake. - **Undecodable values.** A camera value the model cannot decode is published as the string `raw:` (n = the raw number) and logged once per device and value. It is never turned into `null` or into a neighbouring value. @@ -413,23 +455,24 @@ asleep read-only on_air { active, apps[] } read-only gimbal { pan, tilt, invert✎ } pan, tilt: live topic "pose" zoom { factor✎, field_of_view✎ } -tracking { mode✎, profile✎, speed✎ (U-1), target { detected, box } } -framing { auto_zoom✎, size✎, zoom_speed✎, offset { x✎, y✎ }, part✎ } (U-3, U-4) +tracking { mode✎, profile✎, speed✎, target { detected, box } } +framing { auto_zoom✎, size✎, zoom_speed✎, offset { x✎, y✎ } (U-4), part✎ } gestures { target_lock✎, zoom✎, zoom_ratio✎, dynamic_zoom✎, direction_change✎ } zone { enabled✎, follow✎, area { pan✎, tilt✎ }, return } hand { side✎, area { pan✎, tilt✎ }, axes { pan✎, tilt✎ } (U-5), return_after✎ (U-6), move_gimbal✎, return } presets [ { slot, name, pose } ] -views [ { id, name, pose } ] (U-14) -exposure { mode✎, region✎, time✎, gain✎, compensation✎, hdr✎, anti_flicker✎ } +views [ { id, name, pose } ] +exposure { mode✎, region✎, time✎, gain✎, compensation✎ (U-20), hdr✎, + anti_flicker✎ (U-25: the value auto) } image { brightness✎, contrast✎, saturation✎, hue✎, sharpness✎, white_balance { auto✎, temperature✎ }, mirror✎, frame_rate } -focus { mode✎ (U-15), region✎, position✎ (U-22) } -audio { enabled✎, volume✎ (U-17), noise_reduction✎, auto_gain✎, in_sleep✎ } +focus { mode✎, region✎, position✎ } +audio { enabled✎ (U-23), volume✎, noise_reduction✎, auto_gain✎, in_sleep✎ } led { brightness✎, blink✎ } sleep { auto { enabled✎, seconds✎ }, background✎, background_mirror✎, - wake_zoom✎ (U-21) } -power_on { mode✎, start { source, pose, mode✎ (U-18) }, settings { … } (U-19) } + wake_zoom✎ } +power_on { mode✎, start { source (U-26), pose, mode✎ (U-18) }, settings { … } (U-19) } ``` Per group: @@ -441,15 +484,18 @@ Per group: application that starts its video at that moment can fail with "device busy"; 6.17 and newer are not affected. - `gimbal.invert` reverses the gimbal's pan direction. -- `zoom.factor`: 1.0–4.0, `kind: digital`. It follows `zoom.field_of_view`, the zoom +- `zoom.factor`: 1.0–4.0. It follows `zoom.field_of_view`, the zoom gestures and `preset.recall`. - `zoom.field_of_view`: values `wide|medium|narrow|custom`, writable `wide|medium|narrow`. - `tracking.mode`: `static|normal|upper_body|close_up|headless|lower_body|hand|group`. A mode change rewrites `framing.*`: `upper_body` and `close_up` set `framing.auto_zoom` and `framing.size`; `headless` and `lower_body` set `framing.part`. -- `tracking.profile`: `standard|headroom|sport` (`headroom` is U-2). The vendor app labels - this "tracking speed". +- `tracking.profile`: `standard|headroom|sport`. The vendor app labels this "tracking + speed". - `tracking.speed`: `lazy|slow|normal|fast|very_fast|auto`. +- `tracking.profile=headroom`, `tracking.speed` and `framing.*` read back as written + (measured, #98); their effect on the tracking is not observed yet (CAM-33 decides + `no_effect`). - `tracking.target`: `detected` (bool) and `box` (`{left,top,right,bottom}` as picture fractions, `null` without a target; layout measured, #71). `box` has the live topic `target`. @@ -473,27 +519,31 @@ Per group: `exposure.gain` have the live topic `auto`. `exposure.compensation`: the camera's raw value 0–18, default 9 (U-20). `exposure.anti_flicker`: values `off|50hz|60hz|auto`, writable `off|50hz|60hz` (the kernel driver offers no way to set `auto` on this camera), - no `default`. + no `default`. The value `auto` was never read from the camera (U-25). - `image`: brightness, contrast, saturation, hue and sharpness are UVC controls with the ranges and defaults the camera reports (constants of the model). The camera has no gamma control. `white_balance.temperature` `requires` `white_balance.auto=false` and has the live topic `auto`. `frame_rate` is read-only (fps). - `focus.mode`: `continuous|single|manual`. `focus.region`: `face|global`. `focus.position` 0–100, `requires` `focus.mode=manual`, `no_effect` on this model (#99). -- `audio.enabled`: `warning: restart`; the camera restarts on every write, both values - (about 18 s, measured). Its readback is the presence of the camera's USB audio interface - in sysfs (U-23). `audio.volume` 0–100. `audio.noise_reduction`: `off|weak|medium|strong`. +- `audio.enabled`: `warning: [restart]`; the camera restarts on every write, both values; + the restart lasts about 18 s (measured; how long until the device is enumerated again and + answers is timed in CAM-1). The write is answered once the camera accepted it, and the + restart is ordinary hotplug (section 5.6). Its readback is the presence of the camera's + USB audio interface in sysfs (U-23). `audio.volume` 0–100. `audio.noise_reduction`: + `off|weak|medium|strong`. - `led.brightness` 0–3. `led.blink`: `readback: false`. - `sleep.auto.enabled` and `sleep.auto.seconds` are one word on the camera. The camera keeps the seconds while auto-sleep is off; writing `seconds` alone does not switch it on. Range 1–32767 s: the camera cannot store "off" together with 0 s, so 0 is not offered. `sleep.background`: values `black|image|own_image`, writable `black|image` (`own_image` - shows after an upload, which 1.0 does not offer). `sleep.wake_zoom`: 1.0–4.0, `no_effect`. + shows after an upload, which 1.0 does not offer). `sleep.wake_zoom`: 1.0–4.0, `no_effect` + (#102). - `power_on.mode`: the tracking mode the camera starts in at power-on (0.13 `wake mode`), all eight modes (measured, #102). - `power_on.start`: the camera's initial state (0.13 `startup`). `source` is `own` or - `preset_`, read-only. `pose` is read-only and set through actions. `mode` is the - tracking mode of the initial state: values `static|normal|upper_body|close_up|headless|` + `preset_` (U-26), read-only. `pose` is read-only and set through actions. `mode` is + the tracking mode of the initial state: values `static|normal|upper_body|close_up|headless|` `lower_body|hand|group|unset`, writable `static|normal|upper_body|headless|group|unset` (all but `normal` and `unset` are U-18). `unset` is the camera's "not set" and differs from `static`. @@ -519,24 +569,24 @@ Merged duplicates: `focus.auto` (UVC continuous autofocus) is the same camera bi standard, from 85 far); UIs may offer those three as volume presets. Not offered in 1.0: flipping the image, resetting the start pose, resetting the zone area. -Their candidate commands are neither recorded nor sent by 0.13.2 (CAM-11, CAM-12). +No recording of any of them exists, and 0.13.2 does not offer them (CAM-11, CAM-12). ### 4.4 Actions of the Tiny 2 Lite ``` -gimbal.move {pan?, tilt?, speed? (U-9)} gimbal.stop (U-10) +gimbal.move {pan?, tilt?, speed? (U-9)} gimbal.stop gimbal.turn {pan_speed?, tilt_speed?, keep?} -zoom.move {factor, speed (U-12)} zoom.stop (U-11) +zoom.move {factor, speed (U-12)} zoom.stop preset.save {slot, name?, pose?} preset.recall {slot} preset.rename {slot, name} preset.delete {slot} -view.save {name, pose?} → {id} view.go {id} (U-13) +view.save {name, pose?} → {id} view.go {id} view.rename {id, name} view.delete {id} zone.return_here zone.go_return hand.return_here hand.go_return hand.return_clear hand.area_reset (U-7) focus.point {x, y} (U-16) power_on.set_pose {pose} power_on.use_current -power_on.use_preset {slot} power_on.go (U-24) +power_on.use_preset {slot} power_on.go sleep wake factory_reset {confirm} ``` @@ -566,9 +616,9 @@ factory_reset {confirm} state and keep its tracking mode and `power_on.settings`. - `sleep` and `wake` reply when the camera accepted the command. The state shows `asleep` as the camera reports it; clients wait for that state, never for their own value. -- `factory_reset`: `warning: destructive`, needs `confirm:true`. It replies when the camera - accepted it. The restart that follows is ordinary hotplug (section 5.3). It deletes the - presets. +- `factory_reset`: `warning: [destructive, restart]`, needs `confirm:true`. It replies when + the camera accepted it (5.6). The restart that follows is ordinary hotplug (section 5.3). + It deletes the presets. - Every action states its `asleep` behaviour in the description; section 8.8 lists the Tiny 2 Lite's. @@ -587,11 +637,13 @@ command id, no offset and no selector of any model: everything model-specific co Synchronous threads, no async runtime: - an acceptor; -- per connection one reader thread, which parses requests and routes them, and one writer, - which drains the connection's outbound queue (64 lines; when it is full, the connection is - closed and a log line names it); +- per connection one reader thread, which parses requests, answers the requests of 4.1 + rule 2 that need no worker, and routes the rest, and one writer, which drains the + connection's outbound queue (64 lines; when it is full, the connection is closed and a log + line names it by its connection number and the `client` of its `hello`); - a registry thread, which runs the enumeration (5.3); -- one worker per supported device: the only code that touches that device. +- one worker per supported device: the only code that touches that device. The workers of + several devices run in parallel, their first reads included. A device table (id, description, rev, last state, pending, unread) sits outside the workers under one lock. `hello` takes that lock to build its snapshot and to register the @@ -601,19 +653,30 @@ connection for events. 1. Take the single-instance lock (5.9). Inherit the socket from systemd, or bind it. 2. Run one enumeration pass (5.3). -3. For every supported device: start its worker, which runs the first identity attempt and - a first read of every group. -4. Only then answer `hello`. A device found later enters by a `devices` event after its - first read attempt has finished. The leaves of a group whose first read failed are +3. For every supported device: start its worker. The worker runs the first identity + attempt (5.5), then reads the group that holds `asleep` (on the Tiny 2 Lite the status + block). If that read reports the camera awake, or the model has no `asleep` leaf, the + worker reads every other group once; if it reports asleep, only the groups readable + while asleep. This is the device's first read attempt. +4. Answer `hello` after the first read attempt of every device of step 3, or at the latest + 15 s after the `hello` arrived (4.1, `hello` timing). A device found later enters by a + `devices` event after its first read attempt has ended. Leaves that were not read are `null` and listed in `unread`. ### 5.3 Enumeration and hotplug - Once a second the registry thread lists `/sys/class/video4linux`. For each node it resolves the USB device the node belongs to and reads, from sysfs only: vendor id, product - id, product string, whether a serial string exists, bcdDevice, the interface classes, the - extension units from the USB descriptors, and the node index. It never opens a video node - during the scan. + id, product string, whether a serial string exists, bcdDevice, the device number on its + bus, the interface classes, the extension units from the USB descriptors, and the node + index. It never opens a video node during the scan. The attributes it reads (for example + `idVendor`, `idProduct`, `product`, `bcdDevice`, `devnum`, `descriptors`, the + interfaces' `bInterfaceClass` and the node's `index`) are a choice of track A, confirmed + on the camera by CAM-25. +- One USB device is one entry, whatever number of video nodes it has. +- An enumeration is identified by the USB path together with the device number. When the + device number under a path changes between two scans, the daemon handles it as an unplug + followed by a plug-in, even if no scan saw the path missing. - A USB device whose vendor id is not in the registry's vendor table (section 8.1) is ignored and not listed. - A device with a known vendor id is matched by (vendor id, product id) against the @@ -628,41 +691,62 @@ connection for events. `port:` entry is removed. The re-plugged device takes its old `sn:` entry once its identity is read (5.5). - A camera that re-enumerates (after `factory_reset`, after an `audio.enabled` write, in - firmware-update mode) is ordinary hotplug: the old node goes, a new one comes. A camera in - firmware-update mode has no video node, so the daemon treats it as unplugged and sends it - nothing. + firmware-update mode) is ordinary hotplug: the old node goes, a new one comes. A device + without a video node (a camera in firmware-update mode, for example) is not listed and + receives nothing; a camera that enters that mode counts as unplugged. ### 5.4 Unsupported devices A device with a known vendor id and an unregistered product id gets an entry with `support: unsupported`, `model: unknown`, id `port:`, `serial: null`, `firmware: null`, its `usb` facts, its `video_node`, `available`, and empty `settings`, -`actions` and `limits`. Its state is `{"available":…,"pending":{},"unread":[]}`. The daemon +`actions` and `limits`. Its state is `{"available":true,"pending":{},"unread":[]}` with +`rev` 1, and it never changes: on unplug the entry is removed. It has no worker. The daemon sends it nothing: no identity query, no status read, no on-air probe, no extension-unit -request of any kind. It never opens its video node. One log line per plug-in names its USB -ids. `set` and `do` on it are answered `unsupported`. Section 8.4 gives the whole -walk-through. +request of any kind. It never opens its video node. The connection's reader thread answers +requests for it at once (4.1 rule 2): `get` from the fixed state, `set` and `do` with +`unsupported_device`. Section 8.4 gives the whole walk-through. ### 5.5 Identity - The identity source is a property of the model (section 8.2): the USB serial string when - the model has one, else the model's identity read, else none. -- The model's identity read is sent at plug-in, whether the camera is asleep or awake. At - most three attempts: at plug-in and with the next two reads of the 5 s tier. -- If no attempt gave a valid serial and the camera was asleep, one further series of three - attempts runs from its first wake: at the wake and with the next two reads of the 5 s - tier. After that the id stays as it is until unplug. The id can change at most once per - plug-in. + the model has one (read from sysfs through `Transport`, no request), else the model's + identity read, else none. +- An identity attempt sends the model's identity read and, where the model has one, its + firmware query. The cap below counts attempts, so it covers both queries. +- **First series.** The first attempt is sent at plug-in, whether the camera is asleep or + awake. Attempts 2 and 3 run with the next two reads of the tier the model names for them + (on the Tiny 2 Lite the 5 s tier). That tier is not read while the camera is asleep, so + these attempts wait for the camera to be awake. +- **Wake series.** If the first attempt gave no valid serial and the first read of the + group that holds `asleep` (5.2) reported the camera asleep, the first series ends after + that attempt. One further series of three attempts runs from the camera's first wake: at + the wake and with the next two reads of the named tier. So no more than one attempt is + ever sent to a sleeping camera. +- After the last attempt the id stays as it is until unplug. The id can change at most once + per plug-in. - A serial is valid only if it matches `[A-Za-z0-9]{8,32}`. Without a valid serial the id is - `port:` for this plug-in, with a log line. -- If an available device already holds the same `sn:` id, the newcomer gets a `port:` id and - a log line. + `port:` for this plug-in, with a log line. The pattern is part of the id format + of protocol 2: widening it is a contract change, not a property of a model. +- **Duplicates.** The duplicate rule is applied before the hand-over. If an available device + already holds the same `sn:` id, the newcomer gets a `port:` id for this plug-in and a log + line. The newcomer is the device whose serial was read later; for two serials read in one + enumeration pass, the one later in the order of the scan. - Identity values (serial, firmware) are kept only once the camera answered them. - **Hand-over.** When a `port:` id resolves to `sn:`, the device takes that id. If an - unavailable entry with that id exists, the device takes that entry. The `devices` event - lists the old entry once more with `available:false` and `replaced_by:""`, then - the old entry is gone. Requests to the old id are answered `unknown_device`. Clients move - their selection. + unavailable entry with that id exists, the device takes that entry. The daemon sends, one + directly after the other: + 1. a `devices` event that lists the `sn:` entry (available) and the old `port:` entry with + `available:false` and `replaced_by:""`; + 2. a `devices` event without the `port:` entry; + 3. a `state` event of the `sn:` entry. + + The new state's `rev` is one more than the larger of the `port:` entry's rev and the + taken `sn:` entry's last rev, so a client that kept the `sn:` entry never sees its rev go + down. `pending` and `unread` carry over from the `port:` entry: the device and its worker + are the same. Requests already queued run on and are answered as usual; their events + carry the `sn:` id. Requests that arrive for the old id after event 1 are answered + `unknown_device`. Clients move their selection. ### 5.6 Worker and write path @@ -673,9 +757,16 @@ walk-through. 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 - or enters `pending` with it (4.1 rule 4). While a leaf is pending, its group is also read - at every tick of the 5 s tier. A `readback:false` leaf takes the written value once the - camera accepted the write. + or enters `pending` with it (4.1 rule 4). Its group is then read at its own tier; the + pending check runs at those reads and adds none. A `readback:false` leaf takes the + written value once the camera accepted the write. +- **Restart.** A write of a leaf, or an action, whose `warning` holds `restart` (on the Tiny + 2 Lite `audio.enabled` and `factory_reset`) is answered `ok` once the camera accepted the + frame, without a re-read of the touched groups. A written leaf enters `pending` with its + value; the daemon publishes that state and replies with its rev (4.1 rule 3). An action + replies with the current rev. The restart that follows is ordinary hotplug (5.3): the + device becomes unavailable, which empties `pending`, and the re-enumerated device + publishes what the camera reports. - **Stops.** A stop action jumps the queue (4.1 rule 2). A group read in progress ends at the next frame boundary; its leaves are then listed in `unread`, and the group is read again at its next tick. @@ -690,7 +781,9 @@ walk-through. - **Poll plan.** The model names its groups. Each group has a tier: 1 s, 5 s, 60 s, or `touched` (read at plug-in and after a request that touches it, never periodically). Each group says whether it can be read while asleep. A group may name a live topic and a - faster tier used while at least one connection subscribes that topic. + faster tier used while at least one connection subscribes that topic. A writable leaf + with `readback:true` is in a group with a periodic tier (1 s, 5 s or 60 s), so that it can + leave `pending`; the trait conformance test checks it. - Intervals count from the end of the previous read of that tier, so a slow read never starves the 1 s tier. - Polls run group by group. Queued requests are served between groups, never inside one. @@ -710,7 +803,7 @@ walk-through. | `ANSWER_POLL` | 20 reads, 50 ms apart (about 1 s) per query | the query is unanswered; its group's leaves go to `unread`; a request gets `camera` | | `FRAME_GAP` | 100 ms between two frames of one command, where a model needs it | – | | `NODE_LOCK_WAIT` | 5 s for the per-operation lock on the video node | a request gets `camera`; a poll skips the cycle | -| `REQUEST_DEADLINE` | 15 s from a request's arrival | 4.1 rule 9 | +| `REQUEST_DEADLINE` | 15 s from a request's arrival | 4.1 rule 9; `hello` is answered (4.1, `hello` timing) | | `CLIENT_REPLY_TIMEOUT` | 30 s, in the client library | the client reports a lost daemon (CLI exit 4) | | `ACCEPT_BACKOFF` | 100 ms after a failed accept | – | @@ -725,7 +818,10 @@ bounds each control transfer; its value is camera item CAM-29. `.lock` first. If the lock is held, it exits non-zero with a message that names the lock file. A daemon that binds the socket itself first checks whether something already accepts connections on that path and exits with a message if so; then it removes a - stale socket file and binds under umask 077. + stale socket file and binds under umask 077. A 0.13 daemon that bound the socket itself + 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. - **Failures.** A panic in a worker, the registry thread or the acceptor ends the process @@ -752,8 +848,14 @@ bounds each control transfer; its value is camera item CAM-29. ### 5.11 Logs Logs go to stderr and so to the journal. Serials and `sn:` ids are shortened by the one -formatter of 4.2. No log line prints an identity answer. An unsupported device gets one log -line per plug-in. +formatter of 4.2. No log line prints an identity answer. + +- Every device that appears, in the start-up pass included, gets one line: its id, model, + USB ids, USB path and support level, and for a `port:` id of a supported model the reason + (no serial answered, an invalid serial, a duplicate). +- A hand-over gets one line with the old and the new id. +- A device that goes gets one line with its id. +- A connection closed for a full outbound queue gets one line (5.1). ### 5.12 Fake mode @@ -763,8 +865,8 @@ line per plug-in. `obsbot-device/models/fake`: - `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 `presets`), a setting and an action under a group no client knows, another - preset count, `readback:false` leaves, leaves with `asleep: ignored` and + `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. It enters @@ -820,19 +922,26 @@ obsbot4linux do [= …] obsbot4linux describe [] the device description obsbot4linux devices [--report] device list; --report: USB facts obsbot4linux watch [--live …] hello reply and events as JSON lines - --device needed only with more than one usable device + --device selects a device (see below) --json print the protocol objects ``` -- **Device selection.** `--device` takes the full id, a serial suffix of at least four - characters that matches exactly one device, or a model key that matches exactly one - device. Without `--device` the CLI uses the only usable device (support `tested` or - `untested`, available). With none it exits 3; with several it exits 2 and prints the list. - Unsupported devices never count as usable. -- **Unsupported devices.** `devices`, `devices --report`, `describe` and `watch` work for - them. `status`, `get`, `set` and `do` exit 3 and print: " (USB :) - is not supported by obsbot4linux ; `obsbot4linux devices --report` prints what - an issue report needs". +- **Device selection.** `status`, `get`, `set`, `do` and `describe` act on one device. + `--device` takes the full id, a serial suffix of at least four characters, or a model key + (`unknown` included); it must match exactly one listed device. Without `--device` these + commands use the only usable device (support `tested` or `untested`, available). + Unsupported devices never count as usable, but `--device` can name one. `devices` lists + every device; with `--device` it lists only the matched one. `watch` takes no + `--device`: it prints the events of every device. +- **Unsupported devices.** `devices`, `devices --report` and `watch` show them; + `describe --device ` prints their entry (empty `settings` and `actions`). `status`, + `get`, `set` and `do` addressed to one are refused by the CLI before anything is sent + (exit 3, code `unsupported_device`) with the message: " (USB :) is + not supported by obsbot4linux . `obsbot4linux devices --report` prints what an + issue report needs: https://github.com/hellwand/obsbot4linux/issues". +- **No usable device.** Without `--device` and without a usable device the CLI exits 3 + (code `no_device`) with "no usable OBSBOT camera", followed by the list of unsupported + and unavailable devices, if any, and the `devices --report` hint. - **Value syntax**, one per setting type: bool `on|off|true|false`; enum by name (`upper_body`, also `upper-body`); number `1.5`; range `min,max` (`zone.area.pan=-20,20`); pose `pan,tilt,zoom`; point `x,y`; string as given (quoted for @@ -840,22 +949,47 @@ obsbot4linux watch [--live …] hello reply and events as JS (`do view.save name=Desk pose=0,5,1.2`). A `key=value` argument never starts with `-`; free text after `=` may. - **Output.** Human output is grouped, with units, serials shortened (4.2), and pending - values marked `(pending)`. `--json` prints exactly the protocol objects: the `ok` payload - of the reply, or for `status` the device entry and its state. With `--json`, an error - (usage errors included) prints `{"error":{"code","message"}}` on stdout; help and version - stay plain text. -- **`devices --report`** prints, per device, the `usb` object of 4.2 together with - `model`, `support` and `firmware`. It never prints the serial value: only + values marked `(pending)`. `--json` prints exactly the protocol objects: + - `status`: `{"device":,"rev","state"}`; + - `get`, `set`, `do`: the `ok` payload of the reply; + - `devices`: `{"devices":[…]}`, the list of the `hello` reply; + - `describe`: the device entry (``); with a path or an action, that `Setting` or + `Action` object. + + `describe ` for a path or action not in the device's description exits 2 + with code `unsupported`. + +- **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 + only in the CLI and are never sent by the daemon: + + | Situation | Code | Exit | + | --------------------------------------------------------------------- | -------------------- | ---- | + | bad arguments, a value that does not parse | `usage` | 2 | + | `--device` matches no listed device | `unknown_device` | 2 | + | `--device` matches several devices; several usable devices without it | `ambiguous_device` | 2 | + | no usable device | `no_device` | 3 | + | an unsupported device addressed | `unsupported_device` | 3 | + | no socket, connection refused, no reply within `CLIENT_REPLY_TIMEOUT` | `daemon` | 4 | + | the daemon answers `protocol`, or is older than 1.0 (5.10) | `protocol` | 4 | + + For `ambiguous_device` the CLI prints the matching devices. For `protocol` it prints + "the obsbot4linux daemon speaks protocol , this client speaks 2: install the same + version of both and restart the daemon", or the older-daemon message of 5.10. + +- **`devices --report`** prints JSON in both modes, because it is meant to be pasted into + an issue: `{"devices":[…]}` with, per device, `model`, `support`, `firmware`, + `video_node` and the `usb` object of 4.2. It prints no id and no serial value: only `serial_present`. - **`watch`** sends `hello` with `subscribe:true` and the given live topics, prints the reply as its first line and then every event, one JSON object per line, as received. It reads nothing from stdin. It exits 0 on SIGINT or SIGTERM and 4 when the connection ends. - **Exit codes:** 0 ok; 1 `internal`; 2 invalid input (`invalid`, `out_of_range`, - `unsupported`, `unknown_device`, a usage error, several usable devices without - `--device`); 3 device not usable (`conflict`, `unavailable`, `asleep`, `busy`, `camera`, - no usable device, an unsupported device addressed); 4 daemon unreachable, wrong protocol, - an older daemon, or no reply within `CLIENT_REPLY_TIMEOUT`. + `unsupported`, `unknown_device`, `usage`, `ambiguous_device`); 3 device not usable + (`conflict`, `unavailable`, `asleep`, `busy`, `camera`, `no_device`, + `unsupported_device`); 4 daemon unreachable (`daemon`, `protocol`). - **One-shot commands** send `hello` with `subscribe:false` and read with `get`. The CLI waits for the reply; Ctrl-C closes the connection, which drops a request the daemon has not started (4.1 rule 9). @@ -864,14 +998,16 @@ obsbot4linux watch [--live …] hello reply and events as JS paths with `=`, enum values after `=`, action names and their parameters. They are generated from the CLI definition and the embedded description files (`descriptions/*.json` of real models only, never the fakes), committed under - `packaging/completions/`, and a CLI-crate test fails when they are stale. Tab never - contacts the daemon, and `--device` values are not completed. A new model regenerates the - description file and the completions in its PR; the CLI code does not change. How the - scripts are generated is a choice of track C. -- **Relay fallback.** If the widget cannot open the Unix socket from its shell (track D's - first check, section 10.3), the CLI gets `relay`: it connects, copies stdin lines to the - socket and socket lines to stdout unchanged, and sends nothing of its own. Owner: track - C. It is additive. It is not built unless that check fails. + `packaging/completions/`, and a CLI-crate test fails when they are stale. The CLI embeds + every `descriptions/*.json` file present at build time (a build-script glob), so a new + file needs no CLI code. Tab never contacts the daemon, and `--device` values are not + completed. A new model regenerates the description file and the completions in its PR + with `scripts/regenerate.sh`; the CLI code does not change. How the scripts are generated + is a choice of track C. +- **Relay fallback.** If the widget cannot open the Unix socket from the desktop shell that + hosts it (track D's first check, section 10.3), the CLI gets `relay`: it connects, copies + stdin lines to the socket and socket lines to stdout unchanged, and sends nothing of its + own. Owner: track C. It is additive. It is not built unless that check fails. ## 7. Multiple cameras @@ -886,23 +1022,25 @@ test that needs a daemon with `fake-a`, `fake-b` and `unknown` together. - The registry key is the USB (vendor id, product id) pair, read from sysfs before any I/O. A model lists every pair it answers to. A test fails if a pair belongs to two models. The - product string, the vendor app's model number and the firmware's product code are notes - of the model, never keys. + product string and any other number the camera reports are notes of the model, never + keys. - The **vendor table** lists the OBSBOT vendor ids that make a device visible to the - daemon. It holds only vendor ids read from a unit in hand: today `0x3564` (measured on the - Tiny 2 Lite). A further vendor id is added in a registry PR when a unit with it is at - hand. A device whose vendor id is not in the table is not listed at all. + daemon. It holds only vendor ids read from a unit in hand (8.7): today `0x3564` (measured + on the Tiny 2 Lite). A further vendor id is added in a registry PR when a unit with it is + at hand. A device whose vendor id is not in the table is not listed at all. - Registered today: `tiny2lite`, `3564:fef9` (measured). Nothing else. ### 8.2 What a model provides (`trait Camera`) -A model is one module in `obsbot-device/models/` plus a registry entry (key, display name, -USB pairs, support level, a constructor that takes a `Transport`). Its type implements: +A model is one module in `obsbot-device/models/` plus a registry entry: key, display name, +USB pairs, the GUID of its extension unit, the extension-unit selectors it uses, the tier +its identity attempts follow (5.5), support level, and a constructor that takes a +`Transport`. Its type implements: ```rust pub trait Camera: Send { fn description(&self) -> &Description; // settings, actions, limits; settle_ms - fn identity(&mut self) -> Result; // the model's identity source + fn identity(&mut self) -> Result; // one identity attempt (5.5) fn poll_plan(&self) -> &[Group]; // name, tier, readable asleep, live topic fn read(&mut self, group: &str) -> Result; // leaf path → value fn order(&self, leaves: &[&str]) -> Vec; // safe apply order of a patch @@ -917,9 +1055,9 @@ pub trait Camera: Send { 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 where its captures show the same -layout, its own codec inside its module otherwise. A codec moves into `family` only when a -second model uses it. +model owns its frame codec choice: the family codec when the family's codec tests pass on +the model's captured frames (same framing, CRC and sequence numbers), its own codec inside +its module otherwise. A codec moves into `family` only when a second model uses it. ### 8.3 What is sent @@ -931,17 +1069,16 @@ second model uses it. - The transport wrapper checks every outgoing frame against the active model's table in one place. Anything else is refused with `internal`, logged and never sent. - Independently of the table, the wrapper refuses two whole classes: every extension-unit - selector outside the set the family uses (commands, short commands, query answers, - status block, preset list and entry); and, on the receiver that answers identity queries - (`0x0d` on the Tiny 2 Lite), every frame that is not one of the model's declared identity - queries. + selector outside the selectors the model's registry entry declares; and, on the receiver + that answers identity queries (`0x0d` on the Tiny 2 Lite), every frame that is not one of + the model's declared identity queries. - 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. - **No probing.** Capabilities come from the model's code, never from trying ids. The - extension unit is found by its GUID in the USB descriptor (read from sysfs), never by a - request. + extension unit is found by the GUID its registry entry declares, in the USB descriptor + (read from sysfs), never by a request. ### 8.4 A camera with a known vendor id and an unknown product id (walk-through 2) @@ -950,18 +1087,26 @@ second model uses it. does not know. 2. The daemon creates the unsupported entry of 5.4: id `port:`, `model: unknown`, `support: unsupported`, the USB facts from sysfs, empty settings and actions. It opens no - node and sends nothing. It logs one line with the USB ids. -3. Subscribed clients get a `devices` event with the new entry. The widget lists it as not - supported. -4. `obsbot4linux devices` lists it with "not supported" and exits 0. + node and sends nothing. It logs one line (5.11). +3. Subscribed clients get a `devices` event with the new entry, then its `state` event. The + widget lists it in its device picker with the marker `unsupported`, shows no controls + for it, and shows the hint to run `obsbot4linux devices --report`. +4. `obsbot4linux devices` lists it with the level `unsupported` and exits 0. `obsbot4linux devices --report` prints its USB facts (no serial value). 5. If it is the only device, `status`, `get`, `set` and `do` exit 3 with the message of 6.2. If a supported camera is also connected, the CLI uses that camera without `--device`, because unsupported devices never count as usable. -6. A `set` or `do` that reaches the daemon for it is answered `unsupported`. -7. On unplug its `port:` entry is removed and a `devices` event is sent. -8. The user opens an issue with the `devices --report` output. If the maintainers have the - device at hand, onboarding (8.7) starts at step 1. Otherwise nothing changes. +6. A `set` or `do` that reaches the daemon for it is answered `unsupported_device`. +7. On unplug its `port:` entry is removed and a `devices` event is sent. The widget drops + the entry; if it was selected, the widget selects the only usable device, or none. +8. The user opens an issue at `https://github.com/hellwand/obsbot4linux/issues` with the + `devices --report` output. Then one of three things happens: + - the maintainers have a unit: onboarding (8.7) starts at step 1 and ends in a `tested` + release; + - the user records the unit as its owner (8.7, "Owners"): onboarding starts at step 1, + the owner does steps 1–3, the maintainers steps 4–6, and the model is released as + `untested` (8.6); + - neither: the device stays unsupported. CI runs this walk-through with the `unknown` fake (section 11). @@ -973,8 +1118,13 @@ CI runs this walk-through with the `unknown` fake (section 11). behaviour, `readback`, `settle_ms`. Rows refer to spec sections, never to spec line numbers. - **Levels.** `captured` and `measured` are the only levels that let a field into a - released description. The model module records each field's level as a constant. A test - in stage W3 fails if any field of a model with `support: tested` has another level. + released description. A level belongs to each field, and also to each value of an enum's + `values[]` and `writable[]` and to each bound of a number or range: a value or bound + without its own level is not in the description. The model module records the levels as + constants. A test in stage W3 fails if any field, value or bound of any registered model, + `tested` or `untested`, has another level. +- **Capture keys.** `capture N` names a recording of the Tiny 2 Lite. A further model's + recordings are `capture -N` (`capture tiny3-01`), so keys never collide. - **Fixtures** are single exchanges (request frame and answer), inline in a test or in a small text table, each with its capture key. Recording files never enter the repository. Identity answers and descriptors that hold a serial string are never fixtures; tests use @@ -993,13 +1143,19 @@ CI runs this walk-through with the `unknown` fake (section 11). ### 8.6 Support levels - **`tested`**: the maintainers verified every row of the model's check on a unit (for the - Tiny 2 Lite: every parity row, section 9). The description holds only backed fields. The - README support table names the model and the firmware versions it was tested on. -- **`untested`**: built from an owner's captures (8.7 steps 1–6) without steps 7–8 by the - maintainers. Release notes list it under "Untested models", never as supported. Package - descriptions name tested models only. The description of an untested model has no action - with `warning: destructive`. + Tiny 2 Lite: every parity row, section 9). The description holds only backed fields. +- **`untested`**: built from an owner's captures (8.7 steps 1–6) without step 7 on a unit + by the maintainers. Release notes list it under "Untested models", never as supported. + Package descriptions name tested models only. The description of an untested model has + no action whose `warning` holds `destructive`. - **`unsupported`**: a known vendor id with an unregistered product id (5.4). +- The README support table has one row per registered model: display name, USB ids, + support level, and the firmware versions the maintainers tested (`–` for an untested + model; `not reported` for a model that reports no firmware). +- The release that adds a model has one release-notes line for it: ` (USB +:): tested on firmware `, or under "Untested models": ` (USB :): built from an owner's recordings, not verified by the +maintainers`. - The CLI prints the level next to every device that is not `tested`. The daemon logs it once per plug-in. The widget and the desktop app show a marker for `untested` and `unsupported` devices. @@ -1007,13 +1163,29 @@ CI runs this walk-through with the `unknown` fake (section 11). ### 8.7 Onboarding a model (walk-through 1) A model is added only with a unit in hand and with our own captures or an owner's captures. -Nothing is built for a model before that. Steps, in this order: +A unit in hand is a unit connected to a host of the maintainers, or of an owner who records +it under this section. Nothing is built for a model before that. + +**Owners.** An owner is a user who has a unit and records it for onboarding. The owner runs +steps 1–3 and sends the recordings and the capture log to a maintainer privately, never as +an issue attachment: identity answers in them carry the serial. The way to send them is +agreed in the tracking issue. Sending them is the owner's consent that public fixtures are +cut from them under 8.5. The maintainers keep the recordings privately, never in a +repository, and delete them when the owner asks; fixtures already published stay. An owner +may run step 7 with a dev build (step 7); rows that pass count as `measured`, cited by date +and the issue that records them, but only step 7 by the maintainers on a unit makes the +model `tested`. + +Steps, in this order: 0. **Visibility.** Plug the unit into a Linux host running obsbot4linux 1.0. - If `obsbot4linux devices` lists it as unsupported, go to step 1. - - If it is not listed, its vendor id is not in the vendor table. Read `idVendor` and - `idProduct` of the unit from sysfs (`/sys/bus/usb/devices//`; `lsusb` shows - the same pair), add the vendor id to the table in a registry PR, and start again. + - If it is not listed, read `idVendor` and `idProduct` of the unit from sysfs + (`/sys/bus/usb/devices//`; `lsusb` shows the same pair). If the vendor id is + not in the vendor table, add it in a registry PR and start again. If it is in the + table, the unit has no video node in its current mode (for example firmware-update + mode, 5.3): record the product id and the mode in the tracking issue, bring the unit + into its normal camera mode, and start again. Nothing is sent to the camera in this step. 1. **Descriptor report.** Run `obsbot4linux devices --report --device port:` and keep the output: USB ids, product name, bcdDevice, whether a serial string exists, @@ -1027,59 +1199,74 @@ Nothing is built for a model before that. Steps, in this order: and new value. Record, in this order: 1. the connect sequence: recording on, plug in, start the vendor app, 60 s idle (this also records the idle poll); - 2. each setting the vendor app shows for this model, one value change per step, ending - at the original value; - 3. each action the vendor app offers; - 4. sleep and wake, and a plug-in while the camera sleeps. + 2. each setting the vendor app shows for this model: every value it offers, and the + lowest and the highest value of each range, one change per step, ending at the + original value; + 3. each action the vendor app offers, except a firmware update, which is never recorded + for onboarding; a factory reset last, and only if the owner accepts that the camera + loses its stored data; + 4. sleep and wake from the vendor app; then, with the camera asleep, quit the vendor app + and start it again (its connect sequence against a sleeping camera). Nothing is sent from Linux in this step. -3. **Private set and public fixtures.** The recordings and logs go to the owner's private - notes. Public fixtures are cut from them under 8.5: single exchanges, identity answers - replaced by synthetic values, each with its capture key. +3. **Private set and public fixtures.** The recordings and logs stay private with the + maintainers (see "Owners"). Public fixtures are cut from them under 8.5: single + exchanges, identity answers replaced by synthetic values, each with its capture key. 4. **Model module** `models/` with its registry entry: - USB pairs: every product id from step 1. Display name from the product name. Support `untested` until step 8. + - Extension unit: its GUID from the step 1 report and the selectors seen in the + captures go into the registry entry (8.2, 8.3). - Identity source: the USB serial string if `serial_present`, else the identity query the vendor app sends in the connect sequence, else none (`port:` ids). - - Codec: the family codec if the captured frames match its layout and the extension-unit - GUID matches the family's; otherwise the model's own codec (8.2). - - Poll plan: groups and tiers taken from the captured idle poll, never more often than - the vendor app polls. + - Codec: the family codec if the family's codec tests pass on the captured frames; + otherwise the model's own codec (8.2). + - Poll plan: groups and tiers taken from the captured idle poll. No tier reads a group + more often than the vendor app polls it, the faster tier of a live topic (5.7) + included. - Paths: an existing path where function, unit and frame are the same as on another model (4.2); `values[]` and `writable[]` for other option sets; a new path or enum - value for a new function. This is additive and needs no client change (guarantee A). - - Each field gets its level in the model's ledger (8.5). A field with neither captured - nor measured evidence is not in the description. + value for a new function, with its line in the path dictionary (4.2). This is additive + and needs no client change (guarantee A). + - Each field, value and bound gets its level in the model's ledger (8.5). One with + neither captured nor measured evidence is not in the description. + - No action whose `warning` holds `destructive` while the model is `untested` (8.6). - `asleep` is `unknown` for every field until step 7 measures it. - `settle_ms`: until measured on this model, the largest `settle_ms` of any tested model (today the Tiny 2 Lite's 3000 ms), marked "not measured on this model" in the ledger. 5. **Send table** (8.3), each entry with its capture key. 6. **Tests** without the camera: every encoder against the captured request bytes; every decoder against captured answers; the status decoder against captured blocks with one - setting changed per block; the send-table test; the registry test. - `descriptions/.json` and the completions are regenerated. CI with the fakes stays - green. -7. **Check on the unit** by the maintainers: `scripts/check-model.sh --device ` (9.4). - Every passing row becomes `measured` with the date; `settle_ms` and `asleep` are set from - the measurements; failing fields leave the description. -8. **Release** as `tested`, with the firmware versions it was tested on, a README support - table row and a release-notes line. The CLI needs no change. The widget and the desktop - app show the settings they know at once; a setting new to them needs their own update - (guarantee A). - -A model built from an owner's captures that completed steps 1–6, but not 7–8 by the -maintainers, is released as `untested`. M4 is the first model that completes these steps. + setting changed per block; the send-table test; the registry test; the trait + conformance test (10.3 W0); the evidence-level test (8.5). `scripts/regenerate.sh` + regenerates `descriptions/.json` and the completions, and their stale tests pass. + CI with the fakes stays green. +7. **Check on the unit.** From step 6 on, a dev build of the model may run on the unit; its + transport sends only the model's send table (8.3). The check is + `scripts/check-model.sh --device --asleep` (9.4), run by the maintainers (or by an + owner, see "Owners"). Every passing row becomes `measured` with the date; `settle_ms` is + set from the measured lags and `asleep` from the asleep rows; failing fields, values and + bounds leave the description. +8. **Release** as `tested` when the maintainers ran step 7 on a unit, with the firmware + versions it was tested on, else as `untested`; with the README row and the release-notes + line of 8.6. The CLI needs no change. The widget and the desktop app show the settings + they know at once; a setting new to them needs their own update (guarantee A). + +M4 is the first model that completes these steps. ### 8.8 The Tiny 2 Lite model (`tiny2lite`) - USB `3564:fef9`; no USB serial string (measured: two units collide in `/dev/v4l/by-id`). - The extension unit is found by GUID; the descriptor recorded in capture 01 places it at - unit 2. + The extension unit is found by the GUID of the registry entry; the descriptor recorded + in capture 01 places it at unit 2. Selectors: 2 (framed commands and their answers), 6 + (short commands and the status block), `0x0c` and `0x0d` (the preset list and its + entries). - **Identity:** the serial query `0x18c8` to receiver `0x0d` answers 14 ASCII characters. - It is recorded from the vendor app, which sends it at connect. A read from Linux, asleep and right - after plug-in, is camera item CAM-1. Firmware and model come from the queries the ledger - lists. + It is recorded from the vendor app, which sends it at connect. A read from Linux, asleep + and right after plug-in, is camera item CAM-1. Each identity attempt also sends the + firmware query the ledger lists. Attempts 2 and 3 follow the 5 s tier (5.5). No other + identity query is sent: no field uses its answer. - **Live pose:** query `0x0043` to receiver 3, plus the zoom-factor query. The camera's other pose query arms its switch to static after a zone return (#67) and is not in the send table. @@ -1090,26 +1277,27 @@ maintainers, is released as `untested`. M4 is the first model that completes the 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. + 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. - **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`), and - `tracking.mode` before `framing.*`. The order between `exposure.anti_flicker` and - `exposure.mode` is CAM-13. + `image.white_balance.temperature`; `focus.mode=manual` before `focus.position`), + `exposure.mode` before `exposure.gain`, and `tracking.mode` before `framing.*`. The order + 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. - **Asleep:** `zoom.field_of_view` is `ignored` (measured); `power_on.go` is `ignored` - (0.13.2 behaviour; CAM-2 checks it). The + (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` and `focus.*` do not answer while asleep (0.13.2 behaviour), so a write to them stays pending until the first read after wake. Every other field is `unknown` until CAM-2. - **`readback:false`:** `hand.move_gimbal`, `led.blink` (CAM-6). -- **`settle_ms`:** 3000 for every leaf. This is one model-wide bound taken from the worst - measured case (zone tracking follows 1–3 s after the write, #81; the target-lock status - about 1 s), labelled as such in the ledger. Per-leaf bounds are CAM-5. +- **`settle_ms`:** 3000 for every writable leaf with a readback. This is one model-wide + bound taken from the worst measured case (zone tracking follows 1–3 s after the write, + #81; the target-lock status about 1 s), labelled as such in the ledger. Per-leaf bounds are CAM-5. - **Stops:** `gimbal.stop`, `zoom.stop`. **Continuing:** `gimbal.turn`, stopped by `gimbal.stop`. - **Support:** `untested` on `rebuild` until stage W3. The release candidate carries @@ -1124,9 +1312,9 @@ maintainers, is released as `untested`. M4 is the first model that completes the - Every property of either device is **unknown until captures**: USB ids, serial string, extension unit, frame layout, status block, connect sequence, settings, values, asleep behaviour. Section 14 lists the questions. -- Lite functions are not assumed to carry over. The vendor app treats models differently in - many functions (verified), so "common commands" cannot be assumed. One third-party report - links repeated identity queries to USB drops on a Tiny 3 (unverified); the cap on identity +- Lite functions are not assumed to carry over, and no "common commands" are assumed: every + property of either device is unknown until captures. One third-party report links + repeated identity queries to USB drops on a Tiny 3 (unverified); the cap on identity attempts (5.5) holds for every model. - Until a module exists, a unit of either model is an unsupported device (8.4), as long as its vendor id is in the vendor table. @@ -1139,113 +1327,113 @@ Every command and option of 0.13.2 (`src/cli.rs`) maps to a 1.0 command, or is a exception (9.2). 1.0 is released only when every row is verified on the real Tiny 2 Lite (stage W4) or is an exception. U-n refers to 9.3, CAM-n to section 14. -| 0.13.2 | 1.0 | Note | -| ----------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------ | -| `--json` (global) | `--json` | protocol objects; errors as `{"error":{…}}` | -| `--direct` (global) | – | PX-3 | -| `--help`, `--version` | `--help`, `--version` | | -| `daemon` | `obsbot4linuxd`, started by `obsbot4linux.socket` | | -| `watch` | `watch` | prints events; request forwarding is PX-4 | -| `live-pose on\|off` | `watch --live pose`; a client sends `live:["pose"]` | | -| `detect`, `detect --json` | `devices`, `devices --json` | PX-5 | -| `status`, `status --json` | `status`, `status --json` | new layout (4.3) | -| `sleep` | `do sleep` | | -| `wake` | `do wake` | replies on acceptance | -| `wake zoom F` | `set sleep.wake_zoom=F` | U-21 | -| `wake mode M` | `set power_on.mode=M` | | -| `tracking MODE` | `set tracking.mode=MODE` | | -| `speed standard\|sport` | `set tracking.profile=…` | | -| `speed headroom` | `set tracking.profile=headroom` | U-2 | -| `speed-mode MODE` | `set tracking.speed=MODE` | U-1 | -| `framing auto-zoom on\|off` | `set framing.auto_zoom=…` | U-3 | -| `framing size S` | `set framing.size=S` | U-3 | -| `framing zoom-speed S` | `set framing.zoom_speed=S` | U-3 | -| `framing offset [--x X] [--y Y]` | `set [framing.offset.x=X] [framing.offset.y=Y]` | U-4 | -| `framing part P` | `set framing.part=P` | U-3 | -| `hdr on\|off` | `set exposure.hdr=…` | | -| `exposure global\|face` | `set exposure.mode=auto exposure.region=…` | | -| `exposure manual` | `set exposure.mode=manual` | | -| `exposure manual --time T` | `set exposure.mode=manual exposure.time=` | time now in µs | -| `exposure manual --gain G` | `set exposure.mode=manual exposure.gain=G` | | -| `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` | U-11 | -| `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 stop` | `do gimbal.stop` | U-10 | -| `gimbal rate [--pan P] [--tilt T]` | `do gimbal.turn [pan_speed=P] [tilt_speed=T]` | the CLI sends `keep:true` | -| `gimbal center X Y` (hidden) | – | PX-1 | -| `fov wide\|medium\|narrow` | `set zoom.field_of_view=…` | | -| `led N` | `set led.brightness=N` | | -| `led blink on\|off` | `set led.blink=…` | `readback:false` | -| `mirror on\|off` | `set image.mirror=…` | | -| `invert on\|off` | `set gimbal.invert=…` | | -| `auto-sleep off` | `set sleep.auto.enabled=off` | | -| `auto-sleep N` (N ≥ 1) | `set sleep.auto.enabled=on sleep.auto.seconds=N` | | -| `auto-sleep 0` | – | PX-7 | -| `sleep-background black\|image` | `set sleep.background=…` | | -| `sleep-background-mirror on\|off` | `set sleep.background_mirror=…` | | -| `audio noise L` | `set audio.noise_reduction=L` | | -| `audio gain on\|off` | `set audio.auto_gain=…` | | -| `audio distance near\|standard\|far` | `set audio.volume=10\|50\|100` | the camera's pickup command sets exactly these volumes | -| `audio volume N` | `set audio.volume=N` | U-17 | -| `audio sleep-mic on\|off` | `set audio.in_sleep=…` | | -| `audio mic on\|off` | `set audio.enabled=…` | the camera restarts on both values; U-23 | -| `gesture set target-lock\|zoom\|dynamic-zoom\|direction-change on\|off` | `set gestures.=…` | names with `_` | -| `gesture zoom-ratio R` | `set gestures.zoom_ratio=R` | precision 0.1 | -| `hand right\|left` | `set hand.side=…` | | -| `hand return-here` | `do hand.return_here` | live pose | -| `hand return-go` | `do hand.go_return` | | -| `hand return-reset` | `do hand.return_clear` | | -| `hand area --pan MIN MAX --tilt MIN MAX` | `set hand.area.pan=MIN,MAX hand.area.tilt=MIN,MAX` | U-8 | -| `hand area-reset` | `do hand.area_reset` | U-7 | -| `hand pan\|tilt on\|off` | `set hand.axes.pan=…` / `set hand.axes.tilt=…` | U-5 | -| `hand return-after S` | `set hand.return_after=S` | U-6 | -| `hand gimbal on\|off` | `set hand.move_gimbal=…` | `readback:false` | -| `zone on\|off` | `set zone.enabled=…` | | -| `zone follow on\|off` | `set zone.follow=…` | | -| `zone area --pan MIN MAX --tilt MIN MAX` | `set zone.area.pan=MIN,MAX zone.area.tilt=MIN,MAX` | U-8 | -| `zone return-here` | `do zone.return_here` | live pose | -| `zone return-go` | `do zone.go_return` | | -| `zone view list` | `get views` | U-14 | -| `zone view save NAME` | `do view.save name=NAME` | live pose | -| `zone view save NAME --pan P --tilt T --zoom Z` | `do view.save name=NAME pose=P,T,Z` | | -| `zone view rename ID NAME` | `do view.rename id=ID name=NAME` | | -| `zone view delete ID` | `do view.delete id=ID` | | -| `zone view go ID` | `do view.go id=ID` | U-13 | -| `focus mode face\|global` | `set focus.region=…` | | -| `focus auto on` | `set focus.mode=continuous` | | -| `focus auto off` | `set focus.mode=manual`, or `single` if CAM-14 shows that | CAM-14 | -| `focus af continuous\|single\|manual` | `set focus.mode=…` | U-15 | -| `focus point X Y` | `do focus.point x=X y=Y` | U-16 | -| `focus set N` | `set focus.mode=manual focus.position=N` | U-22 | -| `startup set --pan P --tilt T --zoom Z` | `do power_on.set_pose pose=P,T,Z` | | -| `startup set --preset N` | `do power_on.use_preset slot=N` | | -| `startup set --current` | `do power_on.use_current` | live pose | -| `startup set --ai M\|none` | `set power_on.start.mode=M\|unset` | U-18 | -| `startup set --ai M` | the `do` of the pose option, then `set power_on.start.mode=M` | | -| `startup show` | `get power_on` | U-19 | -| `startup go` | `do power_on.go` | U-24 | -| `image brightness\|contrast\|saturation\|hue\|sharpness V` | `set image.=V` | | -| `image white_balance_auto 0\|1` | `set image.white_balance.auto=off\|on` | | -| `image white_balance_temperature V` | `set image.white_balance.auto=off image.white_balance.temperature=V` | `requires`; CAM-15 | -| `image gain V` | `set exposure.gain=V` | | -| `image power_line_frequency 0\|1\|2` | `set exposure.anti_flicker=off\|50hz\|60hz` | | -| `image backlight_compensation V` | `set exposure.compensation=V` | U-20 | -| `image auto_exposure 1\|3` | `set exposure.mode=manual\|auto` | V4L2 1 = manual, 3 = automatic | -| `image exposure_time V` | `set exposure.mode=manual exposure.time=` | µs | -| `image focus V` | `set focus.mode=manual focus.position=V` | U-22 | -| `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 --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` | | -| `preset delete N` | `do preset.delete slot=N` | CAM-18 | -| `preset recall N` | `do preset.recall slot=N` | | -| `factory-reset --yes` | `do factory_reset confirm=true` | `warning: destructive` | -| `factory-reset` without `--yes` | `do factory_reset` is refused with `invalid` | | +| 0.13.2 | 1.0 | Note | +| ----------------------------------------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `--json` (global) | `--json` | protocol objects; errors as `{"error":{…}}` | +| `--direct` (global) | – | PX-3 | +| `--help`, `--version` | `--help`, `--version` | | +| `daemon` | `obsbot4linuxd`, started by `obsbot4linux.socket` | | +| `watch` | `watch` | prints events; request forwarding is PX-4 | +| `live-pose on\|off` | `watch --live pose`; a client sends `live:["pose"]` | | +| `detect`, `detect --json` | `devices`, `devices --json` | PX-5 | +| `status`, `status --json` | `status`, `status --json` | new layout (4.3) | +| `sleep` | `do sleep` | | +| `wake` | `do wake` | replies on acceptance | +| `wake zoom F` | `set sleep.wake_zoom=F` | `no_effect` (#102) | +| `wake mode M` | `set power_on.mode=M` | | +| `tracking MODE` | `set tracking.mode=MODE` | | +| `speed standard\|sport` | `set tracking.profile=…` | | +| `speed headroom` | `set tracking.profile=headroom` | effect: CAM-33 | +| `speed-mode MODE` | `set tracking.speed=MODE` | effect: CAM-33 | +| `framing auto-zoom on\|off` | `set framing.auto_zoom=…` | effect: CAM-33 | +| `framing size S` | `set framing.size=S` | effect: CAM-33 | +| `framing zoom-speed S` | `set framing.zoom_speed=S` | effect: CAM-33 | +| `framing offset [--x X] [--y Y]` | `set [framing.offset.x=X] [framing.offset.y=Y]` | U-4 | +| `framing part P` | `set framing.part=P` | effect: CAM-33 | +| `hdr on\|off` | `set exposure.hdr=…` | | +| `exposure global\|face` | `set exposure.mode=auto exposure.region=…` | | +| `exposure manual` | `set exposure.mode=manual` | | +| `exposure manual --time T` | `set exposure.mode=manual exposure.time=` | time now in µs | +| `exposure manual --gain G` | `set exposure.mode=manual exposure.gain=G` | | +| `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 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 center X Y` (hidden) | – | PX-1 | +| `fov wide\|medium\|narrow` | `set zoom.field_of_view=…` | | +| `led N` | `set led.brightness=N` | | +| `led blink on\|off` | `set led.blink=…` | `readback:false` | +| `mirror on\|off` | `set image.mirror=…` | | +| `invert on\|off` | `set gimbal.invert=…` | | +| `auto-sleep off` | `set sleep.auto.enabled=off` | | +| `auto-sleep N` (N ≥ 1) | `set sleep.auto.enabled=on sleep.auto.seconds=N` | | +| `auto-sleep 0` | – | PX-7 | +| `sleep-background black\|image` | `set sleep.background=…` | | +| `sleep-background-mirror on\|off` | `set sleep.background_mirror=…` | | +| `audio noise L` | `set audio.noise_reduction=L` | | +| `audio gain on\|off` | `set audio.auto_gain=…` | | +| `audio distance near\|standard\|far` | `set audio.volume=10\|50\|100` | pickup is the volume in bands (4.3): near < 50, standard 50–84, far ≥ 85; 10, 50 and 100 are a choice inside them | +| `audio volume N` | `set audio.volume=N` | | +| `audio sleep-mic on\|off` | `set audio.in_sleep=…` | | +| `audio mic on\|off` | `set audio.enabled=…` | the camera restarts on both values; U-23 | +| `gesture set target-lock\|zoom\|dynamic-zoom\|direction-change on\|off` | `set gestures.=…` | names with `_` | +| `gesture zoom-ratio R` | `set gestures.zoom_ratio=R` | precision 0.1 | +| `hand right\|left` | `set hand.side=…` | | +| `hand return-here` | `do hand.return_here` | live pose | +| `hand return-go` | `do hand.go_return` | | +| `hand return-reset` | `do hand.return_clear` | | +| `hand area --pan MIN MAX --tilt MIN MAX` | `set hand.area.pan=MIN,MAX hand.area.tilt=MIN,MAX` | U-8 | +| `hand area-reset` | `do hand.area_reset` | U-7 | +| `hand pan\|tilt on\|off` | `set hand.axes.pan=…` / `set hand.axes.tilt=…` | U-5 | +| `hand return-after S` | `set hand.return_after=S` | U-6 | +| `hand gimbal on\|off` | `set hand.move_gimbal=…` | `readback:false` | +| `zone on\|off` | `set zone.enabled=…` | | +| `zone follow on\|off` | `set zone.follow=…` | | +| `zone area --pan MIN MAX --tilt MIN MAX` | `set zone.area.pan=MIN,MAX zone.area.tilt=MIN,MAX` | U-8 | +| `zone return-here` | `do zone.return_here` | live pose | +| `zone return-go` | `do zone.go_return` | | +| `zone view list` | `get views` | | +| `zone view save NAME` | `do view.save name=NAME` | live pose | +| `zone view save NAME --pan P --tilt T --zoom Z` | `do view.save name=NAME pose=P,T,Z` | | +| `zone view rename ID NAME` | `do view.rename id=ID name=NAME` | | +| `zone view delete ID` | `do view.delete id=ID` | | +| `zone view go ID` | `do view.go id=ID` | | +| `focus mode face\|global` | `set focus.region=…` | | +| `focus auto on` | `set focus.mode=continuous` | | +| `focus auto off` | `set focus.mode=manual`, or `single` if CAM-14 shows that | CAM-14 | +| `focus af continuous\|single\|manual` | `set focus.mode=…` | | +| `focus point X Y` | `do focus.point x=X y=Y` | U-16 | +| `focus set N` | `set focus.mode=manual focus.position=N` | `no_effect` (#99) | +| `startup set --pan P --tilt T --zoom Z` | `do power_on.set_pose pose=P,T,Z` | | +| `startup set --preset N` | `do power_on.use_preset slot=N` | `power_on.start.source`: U-26 | +| `startup set --current` | `do power_on.use_current` | live pose | +| `startup set --ai M\|none` | `set power_on.start.mode=M\|unset` | U-18 | +| `startup set --ai M` | the `do` of the pose option, then `set power_on.start.mode=M` | | +| `startup show` | `get power_on` | U-19 | +| `startup go` | `do power_on.go` | | +| `image brightness\|contrast\|saturation\|hue\|sharpness V` | `set image.=V` | | +| `image white_balance_auto 0\|1` | `set image.white_balance.auto=off\|on` | | +| `image white_balance_temperature V` | `set image.white_balance.auto=off image.white_balance.temperature=V` | `requires`; CAM-15 | +| `image gain V` | `set exposure.gain=V` | | +| `image power_line_frequency 0\|1\|2` | `set exposure.anti_flicker=off\|50hz\|60hz` | | +| `image backlight_compensation V` | `set exposure.compensation=V` | U-20 | +| `image auto_exposure 0\|1\|3` | `set exposure.mode=auto\|manual\|auto` | V4L2 1 = manual; 0.13.2 writes 0 for automatic, and 3 maps to `auto` too | +| `image exposure_time V` | `set exposure.mode=manual exposure.time=` | µs | +| `image focus V` | `set focus.mode=manual focus.position=V` | `no_effect` (#99) | +| `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 --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` | | +| `preset delete N` | `do preset.delete slot=N` | CAM-18 | +| `preset recall N` | `do preset.recall slot=N` | | +| `factory-reset --yes` | `do factory_reset confirm=true` | `warning: [destructive, restart]` | +| `factory-reset` without `--yes` | `do factory_reset` is refused with `invalid` | | ### 9.2 Parity exceptions @@ -1258,9 +1446,9 @@ exception (9.2). 1.0 is released only when every row is verified on the real Tin - **PX-4** request forwarding of `watch` from stdin: removed. The widget speaks protocol 2 on the socket; `relay` is the fallback (6.2). - **PX-5** `detect` without a daemon and with exit 1: replaced by `devices`, which needs the - daemon and exits 0 when it lists a device and 3 when the list is empty. A script that - needs a usable camera reads `support` from `devices --json`, or runs `status`, which exits - 3 without one. + daemon and exits 0 when it lists a device and 3 (`no_device`) when the list is empty. A + script that needs a usable camera reads `support` from `devices --json`, or runs + `status`, which exits 3 without one. - **PX-6** `zoom F --speed S` with any integer S: only `slow|medium|fast` (10, 30, 50), the only speeds with a known meaning. - **PX-7** `auto-sleep 0`: not offered. The camera cannot store "off" together with 0 s, and @@ -1270,65 +1458,78 @@ exception (9.2). 1.0 is released only when every row is verified on the real Tin ### 9.3 Tiny 2 Lite fields without backing today -These fields and values are 0.13.2 functions whose write and readback (or effect) are -neither captured nor measured. One rule holds for every row: the description written in -stage W0 contains the field or value (it is a 0.13.2 function); the check runs on the -camera in session CS-1 (stage A, camera item CAM-23); a field or value that passes is kept -with level `measured`; one that fails leaves the description by a contract-change PR before -the release candidate and becomes the named exception. The release candidate contains no -row that has not passed. No field stays on a guess, and none disappears silently. - -| U | Field or action | Backing today | Check in CS-1 | Fail: exception and 0.13.2 rows lost | -| ---- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| U-1 | `tracking.speed` | readback answers; no recorded write | set each value, read back | PX-U-1: `speed-mode` | -| U-2 | `tracking.profile=headroom` | `standard` and `sport` captured; `headroom` never tested | set, read back, watch the framing with a person in view | PX-U-2: `speed headroom` (value stays read-only) | -| U-3 | `framing.auto_zoom`, `.size`, `.zoom_speed`, `.part` | readbacks used by 0.13; no recorded write | set each value, read back | PX-U-3: the failing `framing` subcommand | -| U-4 | `framing.offset.x`, `.y` | readbacks used by 0.13; range −0.16..0.16 not confirmed | set both bounds and 0, read back | PX-U-4: `framing offset` | -| U-5 | `hand.axes.pan`, `.tilt` | no recording; effect not observed | set, read back; hand tracking with a person in view | PX-U-5: `hand pan`, `hand tilt` | -| U-6 | `hand.return_after` | no recorded write | set, read back | PX-U-6: `hand return-after` | -| U-7 | `hand.area_reset` | no recording | narrow the area, reset, read back the default area | PX-U-7: `hand area-reset` | -| U-8 | `hand.area`, `zone.area` with a new range wholly beyond the stored one | frame order unmeasured | write such ranges for pan and tilt, read back | the model sends the bounds in the order the check shows; re-checked | -| U-9 | `gimbal.move` with `speed` | no recording; tilt 86–90° and speeds 178–180 °/s unmeasured; one axis with speed untested | moves with speed to tilt 86, 88, 90; speed 178 and 180; pan only | range narrowed to the measured one; PX-U-9: `gimbal --speed` if no move with speed works | -| U-10 | `gimbal.stop` | no recording | start a turn, stop it, the pose stops changing | PX-U-10: `gimbal stop`, and `gimbal rate` with it (no turn without a stop) | -| U-11 | `zoom.stop` | no recording | slow zoom move, stop, the factor stops changing | PX-U-11: `zoom stop` | -| U-12 | `zoom.move` speeds `slow` (10), `fast` (50) | only 30 captured | a move at each speed, factor reached | the speed leaves `writable`; PX-U-12: `zoom --speed` with that speed | -| U-13 | `view.go` | never recorded or tested | save a view, move away, go, compare the pose | PX-U-13: `zone view go` | -| U-14 | `views` (the read) | the three reads not recorded; 0.13 lists views | list after save, rename, delete | PX-U-14: all `zone view` rows | -| U-15 | `focus.mode` values `single`, `manual` | only the continuous bit measured | set each value, read back, observe the focus | the value leaves `writable`; PX-U-15: `focus af single\|manual` | -| U-16 | `focus.point` | no recording | point at an object, observe the focus | PX-U-16: `focus point` | -| U-17 | `audio.volume` | readback used by 0.13; no recorded write | set 10, 50, 100, read back | PX-U-17: `audio volume`, `audio distance` | -| U-18 | `power_on.start.mode`: writing `static`, `upper_body`, `headless`, `group`; names of read values other than `normal` and `unset` | only `normal` and `unset` recorded from the vendor app | set each value, read the block, power-cycle, observe the start mode | the value leaves `writable`; an unconfirmed read value shows as `raw:`; PX-U-18: `startup set --ai` with that value | -| U-19 | `power_on.settings.*` | block partly decoded | change each in the vendor app's initial-state dialog (recorded), read the block | the leaf leaves the description; PX-U-19: that part of `startup show` | -| U-20 | `exposure.compensation` in EV | raw range 0–18 and default 9 captured (capture 12); 1/3 EV per step inferred from two clicks | compare the vendor app's displayed steps over the whole range | stays the raw value (no exception: the raw value is the 0.13 function) | -| U-21 | `sleep.wake_zoom` | readback answers; no effect observed (#102) | set, read back | PX-U-21: `wake zoom` | -| U-22 | `focus.position` | no effect observed (#99) | set in manual focus, read back | PX-U-22: `focus set`, `image focus` | -| U-23 | `audio.enabled` readback | the audio interface disappears and returns on toggle (measured); cold plug with the microphone off not measured | cold plug with the microphone off; compare the status-block candidate | readback from the status block if it follows; else `readback:false` | -| U-24 | `power_on.go` | the command 0.13 sends is not recorded | move away, run it, the camera goes to the start pose | PX-U-24: `startup go` | +These fields, values and bounds are 0.13.2 functions whose write and readback (or effect) +are neither captured nor measured today; levels are kept per field, value and bound +(section 8.5). One rule holds for every row: the description written in stage W0 contains +the field, value or bound (it is a 0.13.2 function); the check runs on the camera in +session CS-1 (stage A, camera item CAM-23); what passes is kept with level `measured`; +what fails leaves the description by a contract-change PR before the release candidate and +becomes the named exception. The release candidate contains no row that has not passed. No +field stays on a guess, and none disappears silently. The numbers U-1, U-2, U-3, U-10, +U-11, U-13, U-14, U-15, U-17, U-21, U-22 and U-24 belonged to fields that the field ledger +shows as backed (measured 2026-10-31 or captured); they are not reused. + +| U | Field, value or bound | Backing today | Check in CS-1 | Fail: exception and 0.13.2 rows lost | +| ---- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| U-4 | `framing.offset.x`, `.y`: the bounds −0.16..0.16 and the direction | values read back (measured, #98); bounds and direction not checked | set both bounds and 0, read back; observe the direction with a person in view | bounds narrowed to the measured ones; PX-U-4: `framing offset` if no value works | +| U-5 | `hand.axes.pan`, `.tilt` | no recording; effect not observed | set, read back; hand tracking with a person in view | PX-U-5: `hand pan`, `hand tilt` | +| U-6 | `hand.return_after` | no recorded write | set, read back | PX-U-6: `hand return-after` | +| U-7 | `hand.area_reset` | no recording | narrow the area, reset, read back the default area | PX-U-7: `hand area-reset` | +| U-8 | `hand.area`, `zone.area` with a new range wholly beyond the stored one | frame order unmeasured | write such ranges for pan and tilt, read back | the model sends the bounds in the order the check shows; re-checked | +| U-9 | `gimbal.move` with `speed`: tilt 86–90°, speeds 178–180 °/s, one axis with speed | speeds 1, 5 and 100 °/s to tilt 80 and pan 145 measured (#98); the rest not measured | moves with speed to tilt 86, 88, 90; speed 178 and 180; pan only | range narrowed to the measured one; PX-U-9: `gimbal --speed` if no move with speed works | +| U-12 | `zoom.move` speeds `slow` (10), `fast` (50) | only 30 captured | a move at each speed, factor reached | the speed leaves `writable`; PX-U-12: `zoom --speed` with that speed | +| U-16 | `focus.point` | accepted, and the point reads back (measured, #97); the picture blurs instead of focusing (#99) | point at an object, observe the focus | PX-U-16: `focus point` | +| U-18 | `power_on.start.mode`: writing `static`, `upper_body`, `headless`, `group`; names of read values other than `normal` and `unset` | only `normal` and `unset` recorded from the vendor app | set each value, read the block, power-cycle, observe the start mode | the value leaves `writable`; an unconfirmed read value shows as `raw:`; PX-U-18: `startup set --ai` with that value | +| U-19 | `power_on.settings.*`: values other than the recorded one | each leaf captured with one value (capture 08b); the meaning of other values not recorded | change each in the vendor app's initial-state dialog (recorded), read the block | the leaf leaves the description; PX-U-19: that part of `startup show` | +| U-20 | `exposure.compensation` in EV | raw range 0–18 and default 9 captured (capture 12); 1/3 EV per step inferred from two clicks | compare the vendor app's displayed steps over the whole range | stays the raw value (no exception: the raw value is the 0.13 function) | +| U-23 | `audio.enabled` readback | the audio interface disappears and returns on toggle (measured); cold plug with the microphone off not measured | cold plug with the microphone off; look for a status-block value that follows the microphone | readback from the status block if one follows; else `readback:false` | +| U-25 | `exposure.anti_flicker` value `auto` (read-only) | never read from the camera; no recording shows it | if the vendor app offers automatic anti-flicker: set it there (recorded), then read the status block from Linux; if not, the check fails | the value leaves `values`; the camera's value shows as `raw:3`; no 0.13.2 row is lost | +| U-26 | `power_on.start.source` values `preset_` | the action is captured (capture 10); no readback after it | `do power_on.use_preset slot=N` for each slot, read `power_on.start.source` | the values leave `values`; such a source shows as `raw:`; no 0.13.2 row is lost | ### 9.4 Check script -`scripts/check-model.sh --device [--destructive]` checks one device against its own -description. It replaces 0.13's `check-readback.sh`. +`scripts/check-model.sh --device [--asleep] [--destructive]` checks one device against +its own description. It replaces 0.13's `check-readback.sh`. - It requires `--device`. It reads `describe --json` and builds its rows from it: every writable enum value, both values of each bool, minimum and maximum of each number and range. -- Per row: `set`, wait until a state with `rev` ≥ the reply's rev shows the path outside - `pending`, compare with the written value, restore. -- `readback:false` rows are checked as "accepted"; `no_effect` rows are checked by readback - only. The script says so per row. -- Actions are printed as a checklist with the expected state change and run one by one - after a confirmation. Rows that need a person in view are marked. -- Rows with `warning` (restart, destructive) run only with `--destructive`, last, after - presets and views are printed. Such a row passes when the device returns under the same - id with the expected state. +- **Setting rows.** Per row: `set`, then wait up to 70 s (the slowest periodic tier, 60 s, + plus 10 s) for a state that shows the written value. The row passes when the value is + shown; then the script restores the value. The time from the reply to that state is the + row's lag, and the results file notes whether the value showed in the state the reply + published (the touched re-read) or at a later read of the leaf's group. A lag seen at a + later read is known only to the resolution of that group's tier. +- **`settle_ms` from the lags.** Where every row of a leaf showed its value at the touched + re-read or at a read of a 1 s group, the ledger sets `settle_ms` to the largest lag, + rounded up to the next whole second. Where a value showed only at a later read of a 5 s + or 60 s group, the lag is a tier artefact: the leaf keeps the model-wide bound, and the + ledger notes "below ". A path can stay pending for up to its group's tier, because + `pending` is checked only at the group's own reads (5.6). +- **`readback:false` rows** pass only when the person running the script confirms the + effect (for example, the LED blinks); the script prints what to look for and asks. + `no_effect` rows are checked by readback only. The script says per row which kind it is. +- **Actions** are a checklist, run one by one after a confirmation: the script prints the + action, runs it, and prints the state change it saw (paths before and after). The person + compares that change and the visible effect with the effect column of the model's ledger + and answers pass or fail. Rows that need a person in view are marked. +- **Asleep rows** (with `--asleep`), for every writable leaf and action whose `asleep` is + `unknown`: `do sleep`, wait up to 30 s for `asleep:true`, send the write or the action, + record the reply; `do wake`, wait up to 30 s for `asleep:false`, then wait up to 70 s for + the written value (for an action, the person confirms the effect). The row's result is + `accepted` when the value or the effect is there after the wake, and `ignored` when the + camera refused it or the value or effect is not there. The ledger takes the results. +- Rows whose `warning` holds `restart` or `destructive` run only with `--destructive`, last, + after presets and views are printed. Such a row passes when the device returns under the + same id within 60 s with the expected state. - It snapshots the state first and restores it through a `trap` on EXIT, INT and TERM: the snapshot's tracking mode and pose, never hard-coded values. A field whose snapshot value is `null` is skipped. - Output: one line per row and a results file. The ledger update takes `measured` and the date from it. -- CI runs it against `fake-a` and `fake-b`. In stage W4 the release PR maps its results to - every row of 9.1. +- CI runs it against `fake-a` and `fake-b` with `--assume-pass`, which answers every + question to the person with pass. In stage W4 the release PR maps its results to every row + of 9.1. ## 10. Way of working @@ -1345,7 +1546,7 @@ description. It replaces 0.13's `check-readback.sh`. `refs/heads/rebuild` requires signed commits and the `required` check and blocks deletion. Both are removed after the final merge. - Each stage or track is one or more PRs into `rebuild`, each with the `required` gate and - an Opus review. + a review by someone other than its author (a maintainer, or a review agent run by one). - **Contract-change rule.** A change to the `obsbot-proto` types, the description format, `trait Camera`, `trait Transport` or the schema goes first as its own small PR into `rebuild`. Tracks rebase onto it and never change those files inside a track PR. @@ -1379,7 +1580,7 @@ description. It replaces 0.13's `check-readback.sh`. ### 10.3 Stages of the rewrite After stage G is merged into `rebuild` (its release as 0.14.0 may wait for the camera), the -rewrite runs as W0, then four parallel tracks A–D, then W2, W3 and W4. +rewrite runs as W0, then W1 (four parallel tracks A–D), then W2, W3 and W4. **W0: contracts** @@ -1397,7 +1598,8 @@ rewrite runs as W0, then four parallel tracks A–D, then W2, W3 and W4. - **Acceptance without camera:** the workspace builds and its tests pass; the schema stale 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`); the + 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. - **Acceptance with camera:** none. @@ -1419,7 +1621,8 @@ rewrite runs as W0, then four parallel tracks A–D, then W2, W3 and W4. send-table test; the registry test; the serial-format test; each audit defect in carried code fixed or named in section 14. - **Acceptance with camera (CS-1):** the live tests: CAM-1 to CAM-8, CAM-10, CAM-13 to - CAM-20, CAM-22, CAM-23 and CAM-25 (CAM-21 is recorded if it happens and gates nothing). + CAM-20, CAM-22, CAM-23, CAM-25 and CAM-33 (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). @@ -1442,7 +1645,10 @@ rewrite runs as W0, then four parallel tracks A–D, then W2, W3 and W4. `port:` → `sn:` hand-over with `replaced_by`; the unsupported entry receives no frame (the fake transport records none); `hello` waits for the first enumeration; live-topic filtering; a slow subscriber is disconnected; the 64 KiB request limit; the 64-connection - limit; the single-instance lock; the panic policy. + limit; the single-instance lock; the panic policy; `hello` answered by its deadline while + a first read is slow; the hand-over event sequence and its rev; requests for unsupported + and unavailable devices answered at once; a `restart` write answered on acceptance; a + device-number change handled as unplug plus plug. - **Acceptance with camera:** none in this track (W4 covers the real path). **Track C: client and CLI** @@ -1454,28 +1660,35 @@ rewrite runs as W0, then four parallel tracks A–D, then W2, W3 and W4. `packaging/completions/`. - **Depends on:** W0; B-1 for the tests against a running fake daemon. - **Acceptance without camera:** value parsing per setting type; rendering snapshots from - the golden states; one test per error code for its exit code; the `--json` error shape for - a usage error; a non-protocol-2 `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 + the golden states; one test per error code, the CLI-only codes included, for its exit + code; the `--json` error shape for a usage error; the `--json` shapes of `devices` and + `describe`; an unsupported device refused before anything is sent; a non-protocol-2 + `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. - **Acceptance with camera:** none in this track. **Track D: widget (repository `obsbot4linux-dux`)** -- **Content:** first, a check that the widget can open the Unix socket from its shell; if - it cannot, track C adds `relay` (6.2) and the widget uses it. Then the widget on protocol - 2: `hello`, device picker, every control gated on a path or action of 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 obsbot4linux ≥ 1.0 (`plugin.json`: `obsbot4linux >= -0.7.3, < 1.0`). +- **Content:** first, a check that the widget can open the Unix socket from the desktop + shell that hosts it (DankMaterialShell); if it cannot, track C adds `relay` (6.2) and the + widget uses it. Second, a check whether `dms plugins update` follows the widget + repository's default branch or its tags; the answer decides how the upper bound of widget + 0.28.0 reaches users. Then the widget on protocol 2: `hello`, device picker, every + control gated on a path or action of the description, enum values, bounds, step, unit + 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 + 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. - **Acceptance without camera:** against `obsbot4linuxd --fake fake-a,fake-b,unknown`: `fake-b`'s unknown setting does not break the widget; its missing groups hide their - controls; the unsupported device shows as not supported; a value set with the CLI appears + 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 candidate. - **Acceptance with camera:** in W4. @@ -1549,9 +1762,10 @@ Release day, in this order: 1. Widget 0.28.0 (the last 0.x release, with its upper bound) is out. 2. The release candidate `v1.0.0-rc.N` is published and W4 has passed on it. 3. `main` fast-forwards to `rebuild`; tag `v1.0.0`; the release workflow publishes the - packages; the mirror follows. + packages; the maintainers' private mirror of the repository follows the tag. 4. Widget 1.0 is released after the tool, the same day. Its `plugin.json` requires - obsbot4linux ≥ 1.0. The plugin registry entry is updated. + obsbot4linux ≥ 1.0. The widget's entry in the DankMaterialShell plugin registry is + updated. 5. The 0.13.2 packages and widget 0.28.0 stay downloadable for rollback. The release notes name them. Rollback beyond that is a fix forward. @@ -1565,6 +1779,24 @@ Release day, in this order: - both walk-throughs (8.4, 8.7) pass on this spec, and walk-through 2 runs in CI with the `unknown` fake. +### 10.7 After 1.0 + +- `main` is the only long-lived branch. Every change is a PR into `main` with the `required` + gate, signed commits and a review by someone other than its author. +- The contract-change rule of 10.1 stays: a change to the `obsbot-proto` types, the + description format, `trait Camera`, `trait Transport` or the schema goes first, as its + own PR. The changes to the seam that M4 shows to be needed (section 13) follow this rule. +- A model PR follows 8.7: module, registry entry, ledger, fixtures, send table, regenerated + descriptions and completions (`scripts/regenerate.sh`), path dictionary lines, README row. +- Versions follow 12.5. A new model, setting, value, setting type or unit is additive: a + minor version. A fix is a patch version. A breaking protocol change raises the protocol + number and the major version, and runs like 1.0: its own branch, a release candidate and + a live check of every model the maintainers can test. +- A release is a tag `vX.Y.Z` on a commit of `main` whose CI is green; the release workflow + publishes the packages, and the release notes carry the lines of 8.6. A release that + changes the packaging, the units or the protocol publishes a release candidate first, as + in W3. + ## 11. Testing | Level | What | Where | @@ -1594,7 +1826,7 @@ Release day, in this order: `/usr/share/obsbot4linux/protocol-2.json`. - Formats as today: Arch, deb, rpm and tarball, each for x86_64 and aarch64: eight artifacts. aarch64 builds natively. There is no libudev dependency. -- Packages do not enable the socket unit. The existing mirror stays. +- Packages do not enable the socket unit. ### 12.2 Migration notes @@ -1622,8 +1854,8 @@ values by `pending` and `rev`; this replaces its own hold of up to 3 s The desktop app (`obsbot4linux-desktop`, own repository, own spec) uses `obsbot-proto` (types and client). Before `v1.0.0` it builds against a local checkout; from then on it depends on -a git tag ≥ `v1.0.0`. Like the widget, it gates every control on the description and shows -the support markers. +a git tag ≥ `v1.0.0`. Like the widget, it gates every control on the description, takes +values, bounds, step, unit and list limits from it, and shows the support markers. ### 12.5 Versions @@ -1634,11 +1866,11 @@ the protocol number and the major version. ## 13. Later (not 1.0) - **M4:** the first captured second model (Tiny 3 or Tiny 3 Lite, whichever arrives), - through 8.7, plus what it shows the seam lacks. + through 8.7, plus what it shows the seam lacks (contract changes, 10.7). - **M5:** the device lease for the firmware updater (`busy` is reserved for it); whether the updater joins the workspace, and the packaging of its udev rule. - A command catalogue, only once two real models show duplicated tables. -- Models derived only from the vendor app's code are off the roadmap. +- Models without a unit in hand and captures are off the roadmap. - A generated UI (presentation metadata in the description), only with a consumer. - Live lists per device (1.0 has one list per connection). - User-given device names, additive, if two identical cameras ever meet. @@ -1653,10 +1885,9 @@ the protocol number and the major version. Nothing in this list is guessed in the meantime. Each item names the stage that needs it: G (CS-0), A (CS-1), W4 (CS-2), or M4 onboarding. A stage whose acceptance needs an item waits for the camera; work that does not need it goes ahead. Every item is answered -without a frame outside the model's send table, with two exceptions: CAM-11 and CAM-12 need -frames that are neither recorded nor sent by 0.13.2, so they wait for a recording of the -vendor app sending them, or for Thomas' decision to try them; they gate nothing. CAM-21 and -CAM-29 are recorded only if they happen and gate nothing. +without a frame outside the model's send table. CAM-11 and CAM-12 wait for a recording of +the vendor app; nothing is sent for them before it exists, and they gate nothing. CAM-21 +and CAM-29 are recorded only if they happen and gate nothing. **Tiny 2 Lite** @@ -1669,11 +1900,11 @@ CAM-29 are recorded only if they happen and gate nothing. | 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-8 | Is the USB audio interface absent on a cold plug with the microphone off? Does the status-block candidate follow the microphone? | A | U-23 | +| 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` | -| CAM-11 | Does the candidate command flip the image? | none in 1.0 (see above) | `image.flip` (additive, after 1.0) | -| CAM-12 | Do the candidate commands reset the start pose and the zone area? | none in 1.0 (see above) | `power_on.reset`, a zone-area reset (additive, after 1.0) | +| CAM-11 | Can the image be flipped? Only a recording of the vendor app doing it can show how; nothing is sent before. | none in 1.0 (see above) | `image.flip` (additive, after 1.0) | +| CAM-12 | Can the start pose and the zone area be reset? Only a recording of the vendor app doing it can show how; nothing is sent before. | none in 1.0 (see above) | `power_on.reset`, a zone-area reset (additive, after 1.0) | | CAM-13 | Order of `exposure.anti_flicker` and `exposure.mode` writes, in both directions | A | apply order (8.8) | | CAM-14 | After UVC autofocus off, is `focus.mode` `single` or `manual`? | A | rows `focus auto off`, `image autofocus 0` | | CAM-15 | Are the white-balance temperature and the absolute focus refused while their automatic control is on? | A | confirms the `requires` rules (kept either way: a kernel rule) | @@ -1694,6 +1925,7 @@ CAM-29 are recorded only if they happen and gate nothing. | CAM-30 | Smoke test of stage G: `status`, one setting set and read back, one gimbal move, `preset list` | G | release of 0.14.0 | | 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 | **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) @@ -1717,78 +1949,93 @@ everything here is unknown until captures) ## 15. Changes from revision 1 One row per changed decision. **REVERSAL** marks a change to something Thomas approved on -2026-11-01. "Source" names the decision of 2026-11-12 (a letter and number), or "writer" -for a choice made while writing this revision from the review or the audit; each row can be -overruled on its own. Decisions A1–A3 (`audio.pickup` merged into `audio.volume`, the hidden +2026-11-01. "Source" names the decision of 2026-11-12 (a letter and number), "writer" for +a choice made while writing this revision from the review or the audit, or "editor" for a +choice made while applying the reviews of this revision; each row can be overruled on its +own. Decisions A1–A3 (`audio.pickup` merged into `audio.volume`, the hidden `gimbal center` not carried, `gimbal.turn` with `keep`) were confirmed as revision 1 proposed them and are not listed. -| # | Topic | Revision 1 | Revision 2 | Why | Evidence (status) | Source | -| --- | --------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| 1 | **REVERSAL** Protocol typing | typed `State`, `Patch`, `Action` with per-setting builders | typed envelopes and descriptions; state, patch and parameters are path → JSON value, checked at run time (4.1, 6.1) | typed builders need a proto release for every new setting | the widget hard-codes 53 request sites (verified); new models bring settings the Lite lacks (unknown in detail until captures) | C1 | -| 2 | "No client change" | literal, for all clients | guarantee A: no change to proto, client library or CLI; hand-made UIs update only to show new settings (1) | a hand-made UI cannot show a setting it does not know | widget code (verified) | C2 | -| 3 | **REVERSAL** Crates | five: proto, client, device, daemon, CLI | four: the client is a module of `obsbot-proto` (3) | proto and client are always released together | revision 1 dependency graph (verified) | C3 | -| 4 | Schema artefacts | `schema/protocol-2.json` only; completions "from the schema" | protocol schema plus `descriptions/.json`; the CLI embeds the real models' files for completion (3, 6.2) | path completion needs model data and the CLI must not depend on device code | revision 1 contradiction (verified) | C4 | -| 5 | `--device` completion | completed live while the daemon runs | not completed; Tab never contacts the daemon (6.2) | lean; no daemon contact from the shell | none: design choice | writer | -| 6 | Device entry | `id, name, serial, model, firmware, video_node, available, limits{presets,views}` | adds `support`, `usb` with the descriptor facts, `limits` per list path (4.2) | walk-through 2 and onboarding step 1 need them; `devices --report` prints `usb` | walk-throughs 8.4, 8.7 (verified on paper) | C5; `usb` facts writer | -| 7 | Description fields | type, ranges, `readback`, `asleep`, `no_effect` | adds `requires`, `warning`, `default`, `live`, `settle_ms`, `optional`; bounds per type; closed unit list (4.2) | dependencies, restarts, camera defaults and lag must be visible to generic clients | 0.13 refuses exposure time unless manual (verified); the widget resets to UVC defaults (verified) | C6; `warning` on settings writer | -| 8 | Error codes | unknown path = `invalid`; `busy` also during restarts | adds `conflict`; a path not in the description = `unsupported`; `busy` = queue full only (4.1) | a request the current state forbids had no code | 0.13 refuses an empty preset slot (verified) | C7 | -| 9 | **REVERSAL** `epoch` | random `epoch` in the `hello` reply | none; after any reconnect a client rebuilds from `hello` (4.1) | it had no correct use | none: design choice (a reconnect always rebuilds from `hello`) | C8 | -| 10 | Live list | `live:["pose"]`, pose only | live topics named in the description (`pose`, `target`, `auto`); volatile-only changes go only to subscribers (4.1 rule 8) | auto-adjusted values would send an event every second | 0.13 already suppresses them (verified) | C9 | -| 11 | Serial in output | `--json` shortens the id; `devices --show-serial` | `--json` and `watch` carry the full id; human output and logs shorten; `--show-serial` dropped (4.2, 6.2) | a shortened id cannot be passed back; one contract | revision 1 contradiction (verified) | C10; `--show-serial` writer | -| 12 | `--device` matching | name first, then id | full id, a unique serial suffix of ≥ 4 characters, or a unique model key (6.2) | names are gone | none: follows from row 15 | C10 | -| 13 | **REVERSAL** Start mode "not set" | `power_on.start.mode` nullable, `none` = null | the enum value `unset` (4.3) | null now means "not read" | the widget shows "Not set" for an unread start mode (verified) | C11 | -| 14 | **REVERSAL** `gimbal.step` | an action for widget parity | dropped; the widget computes the target and sends `gimbal.move` (4.4) | the camera has no relative move; the base pose was undefined | the widget steps from its last target (verified) | C11 | -| 15 | **REVERSAL** Names | `device.rename`, `names.json` | none; the daemon keeps no file (2, 5.10) | serves two identical cameras nobody has | none: design choice | C12 | -| 16 | `image.reset` | an action for the widget's reset | dropped; the widget sends a patch of `default` values (4.4, 10.3 D) | no camera command; which controls to reset is a product choice | the vendor app resets other controls than the widget (captured, capture 12) | writer | -| 17 | **REVERSAL** Hold timers | "there are no hold timers" | `pending` (path → written value) with a per-leaf `settle_ms`, checked at poll ticks (4.1 rule 4) | without it the old value flickers back after writes; the wait moves into one place | readbacks lag 1–3 s (measured, #81); target lock about 1 s (measured) | D2, J2 | -| 18 | Write path | read back "the affected group"; full read after each command | re-read only the touched groups; a batch shares one re-read; single-leaf coalescing (4.1 rule 9, 5.6) | a slider drag queued behind a full read per step | 0.13.2 reads everything (about 1.4 s) after every command (verified) | D1 | -| 19 | Stops | no rule; replies strictly in order | stops jump the queue and end a running group read (4.1 rule 2) | a stop waited behind a long read | 0.13.2 behaviour (verified) | D8 | -| 20 | Deadlines | "the transport timeout carried over" | named timeouts; 15 s request deadline; bounded outbound queue; at most 64 connections (5.8, 4.1) | unbounded waits; a slow subscriber stalled replies | 0.13.2 timeouts (verified) | D9; 64 connections writer | -| 21 | Asleep writes | `unknown` passed through | `unknown` written; a path whose readback cannot run asleep stays pending until wake (5.7) | the write could never be confirmed | volume and focus readbacks do not answer asleep (0.13.2 behaviour, verified) | D3 | -| 22 | Identity | 3 tries over about 10 s, also after wake; `port:` entries kept | sent at plug-in also asleep; 3 attempts with plug-in and the next two 5 s reads; one series after the first wake; serial format check; duplicate → `port:`; `port:` entries removed on unplug (5.5) | bounded, tied to existing reads; an id changes at most once | serial query asleep: unknown (CAM-1) | D4, J4; duplicates and `port:` removal writer | -| 23 | Unknown devices | undefined; "read model and serial" for every device | listed `unsupported`, nothing sent, `devices --report` (5.4, 8.4) | any frame to an unknown device is a probe | a blind query scan broke the query channel once (measured) | D5 | -| 24 | **REVERSAL** Hotplug | a udev monitor | a sysfs scan once a second, no libudev, no netlink (5.3) | no C dependency; 0.13 already rescans every second | 0.13.2 daemon (verified) | D6 | -| 25 | Start-up | undefined | `hello` answered after the first enumeration; a device announced after its first read (5.2) | a socket-activated `status` saw no camera | revision 1 gap (verified) | D7 | -| 26 | Single instance | lock at `$XDG_RUNTIME_DIR/obsbot4linux.lock`; turn stops on shutdown | lock at `.lock` for every daemon; a clean shutdown stops a turn best effort; a crash leaves the camera (5.9) | test daemons collided; the 0.13 daemon uses the same lock name | 0.13.2 lock (verified) | D10 | -| 27 | **REVERSAL** Hardening | `NoNewPrivileges`, `ProtectSystem`, `DeviceAllow`, tested on Arch and Ubuntu 24.04 | `NoNewPrivileges=yes` only; the rest after 1.0 (5.10) | independent of the rebuild and costly to accept | in user units these lines imply `PrivateUsers` (verified, systemd documentation); no run on the real unit yet (verified) | D11 | -| 28 | **REVERSAL** Mutation tests | a stage-5 gate on device and proto | one pass over the carried code in stage G; no 1.0 gate (10.2, 11) | they test carried code, which moves first | seven sign-flip mutants survive in the carried code (verified) | D11 | -| 29 | Daemon start | "nothing needs re-enabling" | socket only; packages enable nothing; exit 4 prints the enable line; an older daemon is detected (5.10, 12.2) | default widget setups run the daemon as a child of `watch` with the socket disabled | the owner's desktop (verified) | D12 | -| 30 | 0.13 state files | the daemon deletes `state.json`; the guide promised an import | the daemon touches no file; the migration notes say the directory can be deleted (12.2) | "keeps no file at all"; no import exists | revision 1 contradiction (verified) | writer (C12) | -| 31 | Never-send | a deny-list of two ids | a per-model send table checked in the transport, plus refused classes; no raw path (8.3) | a deny-list protects only against the ids it names | verified | E1 | -| 32 | Public text | text that 8.5 does not allow | only what 8.5 allows; evidence by level and opaque key | the repository is public | verified | E2, J8 | -| 33 | **REVERSAL** Fakes | `--fake N`: N identical Tiny 2 Lites with test-only actions | `fake-a`, `fake-b` (deliberately different), `unknown`; test control on a second socket (5.12) | mixed models and unknown devices were never tested | revision 1's fakes cannot exercise mixed models or an unknown device (verified) | F1–F3 | -| 34 | **REVERSAL** M4 and the catalogue | M4 = TOML catalogue plus a model derived from the vendor app | no catalogue; models only with a unit and captures; M4 = the first captured second model (8.7, 13) | "common commands" cannot be assumed | the vendor app treats models differently in many functions (verified); identity-query loops linked to USB drops on a Tiny 3 (one third-party report, unverified) | B3, B4, B5, J7 | -| 35 | Tiny 3 family | – | two models, two modules; nothing built before a unit is at hand; every device fact unknown until captures (8.9) | the two differ in the vendor app | verified for the vendor app; unknown for the devices | B5, J1 | -| 36 | Registry key | "its USB id" | USB (vendor id, product id) pairs, several per model; a vendor table of measured ids only (8.1) | a model may have several product ids | none: design choice | B6 | -| 37 | Extension unit | unit 2 hard-coded | found by GUID in the USB descriptor, read-only (8.3) | no per-model constant, no request | the descriptor holds unit and GUID (captured, capture 01) | B7 | -| 38 | Model seam | one sentence; the daemon section named Lite ids and tiers | three layers in `obsbot-device`; a path-keyed `trait Camera`; the daemon names no id (3.1, 5, 8.2) | per-model data plus per-model flows; a daemon that knows the Lite cannot run another model | about 83 family-generic and 64 Lite-specific knowledge items in the carried code (verified count) | B1, B2 | -| 39 | Live pose query | the poll tier named the query that arms the zone switch | the model uses `0x0043`; the daemon names no query (8.8) | that query arms the switch to static after a zone return | #67 (measured) | B2 (writer) | -| 40 | **REVERSAL** (partial) Delivery | a long-lived `rebuild` branch, five serial stages | stage G first (released as 0.14.0 from `main`); W0, four parallel tracks, W2, W3, W4 (10) | 64 % of the lines are carried; fixes would land twice; packaging first ran on the tag | line count (verified) | H1, H2, J5 | -| 41 | CI on `rebuild` | assumed | `ci.yml` and a ruleset for `rebuild` before its first PR (10.1) | PRs into `rebuild` ran no CI | GitHub settings (verified) | J5 | -| 42 | Widget channel | undefined; `watch` relayed requests in 0.13 | the widget speaks protocol 2 on the socket; `watch` prints events; `relay` as fallback (6.2, 10.3 D) | one contract; no protocol-1 adapter | a socket type in the shell's runtime: unknown | H3 | -| 43 | Release day | widget and tool the same day | widget 0.28.0 with an upper bound first; a release candidate; a "done" list (10.6) | widget 0.27 would accept 1.0 and fail at run time | widget start-up check (verified) | H4 | -| 44 | Milestones | 0.14.0–0.16.0 on `main` as planned on 2026-10-31 | a proposal: 0.14.0 = groundwork, #73 and #89 into 1.0, 0.15.0 and 0.16.0 after 1.0 (10.5) | the groundwork release carries the carried-code fixes | none: planning choice | H5, J6 | -| 45 | Desktop app dependency | git tag; shell starts after stage 1 | a local checkout before `v1.0.0`, a git tag ≥ `v1.0.0` after; SemVer rules (12.4, 12.5) | no tag exists before 1.0 | no tag exists before `v1.0.0` (verified) | H6; checkout writer | -| 46 | Evidence rule | "every function of 0.13 stays" | only backed fields are offered; unbacked Lite fields are checked or become named exceptions (9.3) | the two rules collided for fields without backing | ledger levels (verified) | J3 | -| 47 | Parity script | `check-parity.sh`, set → read back → restore for every row | `check-model.sh` from the description, `--device`, destructive rows last, restore by trap (9.4) | not every row can be restored; a second model has no 0.13 rows | 0.13's readback script restores incompletely (verified) | writer | -| 48 | `image.gamma` | a path, "incl. gamma" | removed; PX-2 | the camera has no gamma control | the recorded UVC descriptor (captured) | writer | -| 49 | Exposure compensation | `image.backlight_compensation`; "a guess" | `exposure.compensation`, the raw value 0–18, default 9; EV only after U-20 | the vendor app's exposure compensation writes this control; its scale is inferred | captured, capture 12; the 1/3 EV step unverified | writer | -| 50 | Anti-flicker | `auto` writable | `auto` read-only; no `default` (4.3) | the kernel driver offers 0–2 on this camera | kernel driver (verified); the camera reports `auto` (measured) | writer | -| 51 | Sleep background | `black\|image` | values add `own_image` (read-only) | the camera reports it after an upload | measured | writer | -| 52 | `gimbal.move` | `pan` and `tilt` required | either may be missing; the camera keeps that axis (4.4) | a pan-only move pulled a stale tilt | 0.13.2 behaviour (verified); the keep value (captured) | writer | -| 53 | Current pose | only `preset.save` and `view.save` named the live pose | every pose-taking action uses the live pose and is refused while asleep until CAM-22 (4.4) | 0.13 used a remembered or V4L2 pose | 0.13.2 code (verified) | writer | -| 54 | Target box | layout an open point; read every second | layout settled; box changes go to the live topic `target` (4.3) | the layout was checked over the preview | #71 (measured) | writer, C9 | -| 55 | Preset read | in the 60 s tier | at plug-in and after the actions that change presets (`touched`, 8.8) | the read writes to the camera; the vendor app never does | captured (no such write in any recording) | writer | -| 56 | `do wake` | replies once the status reports awake | replies on acceptance; the state shows `asleep` (4.4) | an unbounded wait in the worker | wake time unknown (CAM-3) | writer | -| 57 | Null and unread | `null` only for `nullable` | `unread` leaf paths, `raw:` values, `readback:false` lifetime per availability (4.2) | "not set" and "not read" were indistinguishable | 0.13 widget shows "Not set" for unread values (verified) | writer | -| 58 | `audio.enabled` | no readback; `busy` during the restart after "off" | readback from the USB audio interface; restart on both values; reply on acceptance (4.3, 4.4) | the restart is hotplug; a sysfs fact is camera state | the interface follows the toggle (measured); restart on both values (measured) | writer, C7 | -| 59 | Value details | zoom-move speed a raw integer; exposure time "steps in 100 µs" | zoom-move speed `slow\|medium\|fast`; no step promise for exposure time; zoom ratio precision 0.1; `sleep.auto.seconds` 1–32767; values rounded at the source | only known values are offered; the readback limits precision | 0.13.2 code (verified) | writer | -| 60 | Write order | anti-flicker before manual, citing a 0.13 function | `requires` paths first; tracking mode before framing; the anti-flicker order is CAM-13 (8.8) | the cited function has no such step | 0.13.2 code (verified) | writer | -| 61 | On air | `{active, apps[]}` | one sample; a client's own preview counts; kernel note (4.3) | 0.13 read the two at different moments | 0.13.2 code and kernel source (verified) | writer | -| 62 | `available` | from hotplug | only from enumeration; a failed read never flips it (5.3) | 0.13 reported "camera gone" for one failed tick | 0.13.2 daemon (verified) | writer | -| 63 | Poll intervals | carried from 0.13 | counted from the end of the previous read (5.7) | a slow read starved the 1 s tier | 0.13.2 daemon (verified) | writer | -| 64 | `view.save` name | optional | required, as in 0.13 (4.4) | 0.13 requires it; the camera's default is not recorded | 0.13.2 code (verified) | writer | -| 65 | Untested models | marked UNTESTED in docs | a `support` field; no destructive actions; never called supported (8.6) | a field was missing | none: design choice | C5, writer | -| 66 | `--json` errors | not defined for usage errors | `{"error":{"code","message"}}` on stdout, exit 2 (6.2) | clap printed text | 0.13.2 CLI (verified) | writer | -| 67 | Licence note | names the project the code came from | a general rule: the EUPL note follows the code into every file (3.3) | split and copied code must keep it | none: licence obligation | writer | +| # | Topic | Revision 1 | Revision 2 | Why | Evidence (status) | Source | +| --- | --------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | +| 1 | **REVERSAL** Protocol typing | typed `State`, `Patch`, `Action` with per-setting builders | typed envelopes and descriptions; state, patch and parameters are path → JSON value, checked at run time (4.1, 6.1) | typed builders need a proto release for every new setting | the widget hard-codes 53 request sites (verified); new models bring settings the Lite lacks (unknown in detail until captures) | C1 | +| 2 | "No client change" | literal, for all clients | guarantee A: no code change to proto, client library or CLI while the model fits the existing types, units and constraint forms; descriptions and completions are regenerated as data; a new type or unit is an additive contract change first; hand-made UIs take values and bounds from the description and update only to show new settings (1, 6.2) | a hand-made UI cannot show a setting it does not know | widget code (verified) | C2; the fit condition editor | +| 3 | **REVERSAL** Crates | five: proto, client, device, daemon, CLI | four: the client is a module of `obsbot-proto` (3) | proto and client are always released together | revision 1 dependency graph (verified) | C3 | +| 4 | Schema artefacts | `schema/protocol-2.json` only; completions "from the schema" | protocol schema plus `descriptions/.json`; the CLI embeds the real models' files for completion (3, 6.2) | path completion needs model data and the CLI must not depend on device code | revision 1 contradiction (verified) | C4 | +| 5 | `--device` completion | completed live while the daemon runs | not completed; Tab never contacts the daemon (6.2) | lean; no daemon contact from the shell | none: design choice | writer | +| 6 | Device entry | `id, name, serial, model, firmware, video_node, available, limits{presets,views}` | adds `support`, `usb` with the descriptor facts, `limits` per list path (4.2) | walk-through 2 and onboarding step 1 need them; `devices --report` prints `usb` | walk-throughs 8.4, 8.7 (verified on paper) | C5; `usb` facts writer | +| 7 | Description fields | type, ranges, `readback`, `asleep`, `no_effect` | adds `requires`, `warning`, `default`, `live`, `settle_ms`, `optional`; bounds per type; closed unit list (4.2) | dependencies, restarts, camera defaults and lag must be visible to generic clients | 0.13 refuses exposure time unless manual (verified); the widget resets to UVC defaults (verified) | C6; `warning` on settings writer | +| 8 | Error codes | unknown path = `invalid`; `busy` also during restarts | adds `conflict`; a path not in the description = `unsupported`; `busy` = queue full only (4.1) | a request the current state forbids had no code | 0.13 refuses an empty preset slot (verified) | C7 | +| 9 | **REVERSAL** `epoch` | random `epoch` in the `hello` reply | none; after any reconnect a client rebuilds from `hello` (4.1) | it had no correct use | none: design choice (a reconnect always rebuilds from `hello`) | C8 | +| 10 | Live list | `live:["pose"]`, pose only | live topics named in the description (`pose`, `target`, `auto`); volatile-only changes go only to subscribers (4.1 rule 8) | auto-adjusted values would send an event every second | 0.13 already suppresses them (verified) | C9 | +| 11 | Serial in output | `--json` shortens the id; `devices --show-serial` | `--json` and `watch` carry the full id; human output and logs shorten; `--show-serial` dropped (4.2, 6.2) | a shortened id cannot be passed back; one contract | revision 1 contradiction (verified) | C10; `--show-serial` writer | +| 12 | `--device` matching | name first, then id | full id, a unique serial suffix of ≥ 4 characters, or a unique model key (6.2) | names are gone | none: follows from row 15 | C10 | +| 13 | **REVERSAL** Start mode "not set" | `power_on.start.mode` nullable, `none` = null | the enum value `unset` (4.3) | null now means "not read" | the widget shows "Not set" for an unread start mode (verified) | C11 | +| 14 | **REVERSAL** `gimbal.step` | an action for widget parity | dropped; the widget computes the target and sends `gimbal.move` (4.4) | the camera has no relative move; the base pose was undefined | the widget steps from its last target (verified) | C11 | +| 15 | **REVERSAL** Names | `device.rename`, `names.json` | none; the daemon keeps no file (2, 5.10) | serves two identical cameras nobody has | none: design choice | C12 | +| 16 | `image.reset` | an action for the widget's reset | dropped; the widget sends a patch of `default` values (4.4, 10.3 D) | no camera command; which controls to reset is a product choice | the vendor app resets other controls than the widget (captured, capture 12) | writer | +| 17 | **REVERSAL** Hold timers | "there are no hold timers" | `pending` (path → written value) with a per-leaf `settle_ms`, checked at poll ticks (4.1 rule 4) | without it the old value flickers back after writes; the wait moves into one place | readbacks lag 1–3 s (measured, #81); target lock about 1 s (measured) | D2, J2 | +| 18 | Write path | read back "the affected group"; full read after each command | re-read only the touched groups; a batch shares one re-read; single-leaf coalescing (4.1 rule 9, 5.6) | a slider drag queued behind a full read per step | 0.13.2 reads everything (about 1.4 s) after every command (verified) | D1 | +| 19 | Stops | no rule; replies strictly in order | stops jump the queue and end a running group read (4.1 rule 2) | a stop waited behind a long read | 0.13.2 behaviour (verified) | D8 | +| 20 | Deadlines | "the transport timeout carried over" | named timeouts; 15 s request deadline; bounded outbound queue; at most 64 connections (5.8, 4.1) | unbounded waits; a slow subscriber stalled replies | 0.13.2 timeouts (verified) | D9; 64 connections writer | +| 21 | Asleep writes | `unknown` passed through | `unknown` written; a path whose readback cannot run asleep stays pending until wake (5.7) | the write could never be confirmed | volume and focus readbacks do not answer asleep (0.13.2 behaviour, verified) | D3 | +| 22 | Identity | 3 tries over about 10 s, also after wake; `port:` entries kept | first attempt at plug-in also asleep; attempts 2–3 with the next two reads of a tier the model names; if asleep at plug-in, the first series ends and one series runs from the first wake; serial format check; duplicate → `port:`; `port:` entries removed on unplug (5.5) | bounded, tied to existing reads; an id changes at most once | serial query asleep: unknown (CAM-1) | D4, J4; duplicates and `port:` removal writer; series end editor | +| 23 | Unknown devices | undefined; "read model and serial" for every device | listed `unsupported`, nothing sent, `devices --report` (5.4, 8.4) | any frame to an unknown device is a probe | a blind query scan broke the query channel once (measured) | D5 | +| 24 | **REVERSAL** Hotplug | a udev monitor | a sysfs scan once a second, no libudev, no netlink (5.3) | no C dependency; 0.13 already rescans every second | 0.13.2 daemon (verified) | D6 | +| 25 | Start-up | undefined | `hello` answered after the first enumeration; a device announced after its first read (5.2) | a socket-activated `status` saw no camera | revision 1 gap (verified) | D7 | +| 26 | Single instance | lock at `$XDG_RUNTIME_DIR/obsbot4linux.lock`; turn stops on shutdown | lock at `.lock` for every daemon; a clean shutdown stops a turn best effort; a crash leaves the camera (5.9) | test daemons collided; the 0.13 daemon uses the same lock name | 0.13.2 lock (verified) | D10 | +| 27 | **REVERSAL** Hardening | `NoNewPrivileges`, `ProtectSystem`, `DeviceAllow`, tested on Arch and Ubuntu 24.04 | `NoNewPrivileges=yes` only; the rest after 1.0 (5.10) | independent of the rebuild and costly to accept | in user units these lines imply `PrivateUsers` (verified, systemd documentation); no run on the real unit yet (verified) | D11 | +| 28 | **REVERSAL** Mutation tests | a stage-5 gate on device and proto | one pass over the carried code in stage G; no 1.0 gate (10.2, 11) | they test carried code, which moves first | seven sign-flip mutants survive in the carried code (verified) | D11 | +| 29 | Daemon start | "nothing needs re-enabling" | socket only; packages enable nothing; exit 4 prints the enable line; an older daemon is detected (5.10, 12.2) | default widget setups run the daemon as a child of `watch` with the socket disabled | the owner's desktop (verified) | D12 | +| 30 | 0.13 state files | the daemon deletes `state.json`; the guide promised an import | the daemon touches no file; the migration notes say the directory can be deleted (12.2) | "keeps no file at all"; no import exists | revision 1 contradiction (verified) | writer (C12) | +| 31 | Never-send | a deny-list of two ids | a per-model send table checked in the transport, plus refused classes; no raw path (8.3) | a deny-list protects only against the ids it names | verified | E1 | +| 32 | Public text | text that 8.5 does not allow | only what 8.5 allows; evidence by level and opaque key | the repository is public | verified | E2, J8 | +| 33 | **REVERSAL** Fakes | `--fake N`: N identical Tiny 2 Lites with test-only actions | `fake-a`, `fake-b` (deliberately different), `unknown`; test control on a second socket (5.12) | mixed models and unknown devices were never tested | revision 1's fakes cannot exercise mixed models or an unknown device (verified) | F1–F3 | +| 34 | **REVERSAL** M4 and the catalogue | M4 = TOML catalogue plus rudimentary support for further models without captures | no catalogue; models only with a unit and captures; M4 = the first captured second model (8.7, 13) | "common commands" cannot be assumed | none: design choice (a model only with a unit and captures); identity-query loops linked to USB drops on a Tiny 3 (one third-party report, unverified) | B3, B4, B5, J7 | +| 35 | Tiny 3 family | – | two models, two modules; nothing built before a unit is at hand; every device fact unknown until captures (8.9) | nothing is known about either device | none: design choice; every device fact unknown until captures | B5, J1 | +| 36 | Registry key | "its USB id" | USB (vendor id, product id) pairs, several per model; a vendor table of measured ids only (8.1) | a model may have several product ids | none: design choice | B6 | +| 37 | Extension unit | unit 2 hard-coded | found by GUID in the USB descriptor, read-only (8.3) | no per-model constant, no request | the descriptor holds unit and GUID (captured, capture 01) | B7 | +| 38 | Model seam | one sentence; the daemon section named Lite ids and tiers | three layers in `obsbot-device`; a path-keyed `trait Camera`; the daemon names no id (3.1, 5, 8.2) | per-model data plus per-model flows; a daemon that knows the Lite cannot run another model | about 83 family-generic and 64 Lite-specific knowledge items in the carried code (verified count) | B1, B2 | +| 39 | Live pose query | the poll tier named the query that arms the zone switch | the model uses `0x0043`; the daemon names no query (8.8) | that query arms the switch to static after a zone return | #67 (measured) | B2 (writer) | +| 40 | **REVERSAL** (partial) Delivery | a long-lived `rebuild` branch, five serial stages | stage G first (released as 0.14.0 from `main`); W0, four parallel tracks, W2, W3, W4 (10) | 64 % of the lines are carried; fixes would land twice; packaging first ran on the tag | line count (verified) | H1, H2, J5 | +| 41 | CI on `rebuild` | assumed | `ci.yml` and a ruleset for `rebuild` before its first PR (10.1) | PRs into `rebuild` ran no CI | GitHub settings (verified) | J5 | +| 42 | Widget channel | undefined; `watch` relayed requests in 0.13 | the widget speaks protocol 2 on the socket; `watch` prints events; `relay` as fallback (6.2, 10.3 D) | one contract; no protocol-1 adapter | a socket type in the shell's runtime: unknown | H3 | +| 43 | Release day | widget and tool the same day | widget 0.28.0 with an upper bound first; a release candidate; a "done" list (10.6) | widget 0.27 would accept 1.0 and fail at run time | widget start-up check (verified) | H4 | +| 44 | Milestones | 0.14.0–0.16.0 on `main` as planned on 2026-10-31 | a proposal: 0.14.0 = groundwork, #73 and #89 into 1.0, 0.15.0 and 0.16.0 after 1.0 (10.5) | the groundwork release carries the carried-code fixes | none: planning choice | H5, J6 | +| 45 | Desktop app dependency | git tag; shell starts after stage 1 | a local checkout before `v1.0.0`, a git tag ≥ `v1.0.0` after; SemVer rules (12.4, 12.5) | no tag exists before 1.0 | no tag exists before `v1.0.0` (verified) | H6; checkout writer | +| 46 | Evidence rule | "every function of 0.13 stays" | only backed fields are offered; unbacked Lite fields are checked or become named exceptions (9.3) | the two rules collided for fields without backing | ledger levels (verified) | J3 | +| 47 | Parity script | `check-parity.sh`, set → read back → restore for every row | `check-model.sh` from the description, `--device`, destructive rows last, restore by trap (9.4) | not every row can be restored; a second model has no 0.13 rows | 0.13's readback script restores incompletely (verified) | writer | +| 48 | `image.gamma` | a path, "incl. gamma" | removed; PX-2 | the camera has no gamma control | the recorded UVC descriptor (captured) | writer | +| 49 | Exposure compensation | `image.backlight_compensation`; "a guess" | `exposure.compensation`, the raw value 0–18, default 9; EV only after U-20 | the vendor app's exposure compensation writes this control; its scale is inferred | captured, capture 12; the 1/3 EV step unverified | writer | +| 50 | Anti-flicker | `auto` writable | `auto` read-only; no `default` (4.3); the value `auto` itself is U-25 | the kernel driver offers 0–2 on this camera | kernel driver (verified); the camera reporting `auto`: never recorded (U-25) | writer | +| 51 | Sleep background | `black\|image` | values add `own_image` (read-only) | the camera reports it after an upload | captured (capture 18) | writer | +| 52 | `gimbal.move` | `pan` and `tilt` required | either may be missing; the camera keeps that axis (4.4) | a pan-only move pulled a stale tilt | 0.13.2 behaviour (verified); the keep value (captured) | writer | +| 53 | Current pose | only `preset.save` and `view.save` named the live pose | every pose-taking action uses the live pose and is refused while asleep until CAM-22 (4.4) | 0.13 used a remembered or V4L2 pose | 0.13.2 code (verified) | writer | +| 54 | Target box | layout an open point; read every second | layout settled; box changes go to the live topic `target` (4.3) | the layout was checked over the preview | #71 (measured) | writer, C9 | +| 55 | Preset read | in the 60 s tier | at plug-in and after the actions that change presets (`touched`, 8.8) | the read writes to the camera; the vendor app never does | captured (no such write in any recording) | writer | +| 56 | `do wake` | replies once the status reports awake | replies on acceptance; the state shows `asleep` (4.4) | an unbounded wait in the worker | wake time unknown (CAM-3) | writer | +| 57 | Null and unread | `null` only for `nullable` | `unread` leaf paths, `raw:` values, `readback:false` lifetime per availability (4.2) | "not set" and "not read" were indistinguishable | 0.13 widget shows "Not set" for unread values (verified) | writer | +| 58 | `audio.enabled` | no readback; `busy` during the restart after "off" | readback from the USB audio interface; restart on both values; reply on acceptance without a re-read, the restart is hotplug (4.3, 5.6) | the restart is hotplug; a sysfs fact is camera state | the interface follows the toggle (measured); restart on both values (measured) | writer, C7 | +| 59 | Value details | zoom-move speed a raw integer; exposure time "steps in 100 µs" | zoom-move speed `slow\|medium\|fast`; no step promise for exposure time; zoom ratio precision 0.1; `sleep.auto.seconds` 1–32767; values rounded at the source | only known values are offered; the readback limits precision | 0.13.2 code (verified) | writer | +| 60 | Write order | anti-flicker before manual, citing a 0.13 function | `requires` paths first; tracking mode before framing; the anti-flicker order is CAM-13 (8.8) | the cited function has no such step | 0.13.2 code (verified) | writer | +| 61 | On air | `{active, apps[]}` | one sample; a client's own preview counts; kernel note (4.3) | 0.13 read the two at different moments | 0.13.2 code and kernel source (verified) | writer | +| 62 | `available` | from hotplug | only from enumeration; a failed read never flips it (5.3) | 0.13 reported "camera gone" for one failed tick | 0.13.2 daemon (verified) | writer | +| 63 | Poll intervals | carried from 0.13 | counted from the end of the previous read (5.7) | a slow read starved the 1 s tier | 0.13.2 daemon (verified) | writer | +| 64 | `view.save` name | optional | required, as in 0.13 (4.4) | 0.13 requires it; the camera's default is not recorded | 0.13.2 code (verified) | writer | +| 65 | Untested models | marked UNTESTED in docs | a `support` field; no destructive actions; never called supported (8.6) | a field was missing | none: design choice | C5, writer | +| 66 | `--json` errors | not defined for usage errors | `{"error":{"code","message"}}` on stdout, exit 2 (6.2) | clap printed text | 0.13.2 CLI (verified) | writer | +| 67 | Licence note | names the project the code came from | a general rule: the EUPL note follows the code into every file (3.3) | split and copied code must keep it | none: licence obligation | writer | +| 68 | Unsupported devices on the socket | undefined | `unsupported_device` for `set` and `do`; the reader thread answers requests for unsupported and unavailable devices at once; the CLI refuses before sending; CLI-only error codes with fixed exits (4.1, 5.4, 6.2) | one code per situation; no worker for a device that receives nothing | none: design choice | editor | +| 69 | `hello` deadline | `hello` after the first enumeration | `hello` after the first read attempts, at the latest after 15 s (`REQUEST_DEADLINE`); late devices `unread` (4.1, 5.2) | a slow cold read must not make the CLI report a lost daemon | cold read time unknown (CAM-10) | editor (D7) | +| 70 | Hand-over events | one `devices` event with `replaced_by` | two `devices` events, then a `state` event; rev continues above both entries; `pending` and `unread` carry over (5.5) | clients compare revs per id; the old rule left the sequence open | none: design choice | editor | +| 71 | `warning` | one value | a list; `factory_reset` holds `destructive` and `restart`; every `restart` write replies on acceptance (4.2, 5.6) | the daemon must know which writes restart the camera | factory reset and microphone restart (measured) | editor (C6, C7) | +| 72 | Zoom kind | `kind`, `optical_max` on `zoom.factor` | dropped; added when a model with optical zoom arrives (4.2) | no 1.0 model uses them | none: design choice | editor | +| 73 | Pending reads | – | no extra reads while a path is pending; a writable leaf with a readback is in a periodic group (5.6, 5.7) | decision D2 rules out an extra polling loop | none: design choice | editor (D2) | +| 74 | Evidence levels | per field | per field, value and bound; the level test covers untested models too (8.5) | the Tiny 2 Lite needed evidence per value (U-12, U-18, U-25, U-26) | field ledger (verified) | editor | +| 75 | Owners | a model from an owner's captures, no procedure | owners record steps 1–3, send recordings privately, may run step 7 on a dev build; their results count as `measured`; only the maintainers' step 7 makes `tested` (8.4, 8.7) | walk-through 2 ended without a route for users who own a unit | none: design choice | editor (B4, G) | +| 76 | Model query | sent with identity | not sent: no field uses its answer (8.8) | send only what is used | none: design choice | editor | +| 77 | After 1.0 | – | process, versions and releases after 1.0 (10.7) | section 10 covered only the rebuild | none: design choice | editor | +| 78 | Paths across models | "one meaning per path" | a path dictionary in `docs/protocol-2.md` with a naming rule (4.2) | reuse of a path could not be checked | none: design choice | editor | +| 79 | Re-enumeration | sysfs entry gone | USB path plus device number; a change is unplug plus plug (5.3) | a re-enumeration within one scan interval was invisible | none: design choice | editor | +| 80 | Check script | set → read back → restore | adds lag, asleep and confirmation rows; `settle_ms` and `asleep` come from it (9.4) | step 7 set these without a procedure | none: design choice | editor (D2) | +| 81 | Extension unit per model | one family selector set and GUID | GUID and selectors in the registry entry; the class check uses them (8.2, 8.3) | a second model may use another unit or other selectors | unknown until captures | editor (B7) | From 2c1df24b2fad0ba72acbec8c905162b7dbdb09ab Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Thu, 12 Nov 2026 23:05:28 +0100 Subject: [PATCH 04/27] Spec revision 2: second walk-through fixes Close the seven gaps of the second paper walk-through: the poll rate for readbacks the vendor app never polls, destructive actions in onboarding step 7, the end of an identity series, the level of the on-air probe, reads at wake and slot actions on an unread list, the worker's end at unplug and after a restart frame, and the rev of a re-added port: entry. Rows 82-87 of the changes table record them. Co-Authored-By: Claude Opus 5.5 --- docs/field-ledger.md | 6 +- .../2026-11-01-obsbot4linux-1.0-design.md | 277 ++++++++++-------- 2 files changed, 165 insertions(+), 118 deletions(-) diff --git a/docs/field-ledger.md b/docs/field-ledger.md index 1c98b4e..5f4ed5a 100644 --- a/docs/field-ledger.md +++ b/docs/field-ledger.md @@ -18,7 +18,7 @@ the camera's left; the model converts to the units of spec §4.2. Preset layout: **Evidence** (the levels defined in the spec's preamble). `capture N`: recorded from the vendor app in capture N. `measured `: measured on the camera on that date; `#N` names the public -issue or pull request that records it. `none`: no backing today. `daemon`: not a camera value. +issue or pull request that records it. `none`: no backing today. `daemon`: not a camera value (spec §8.5). Levels hold per field, value and bound (spec §8.5): where values or bounds of one row have different evidence, the evidence column names each. Column §9.3: the row of spec §9.3 for the field, value or bound that has no backing today. @@ -44,8 +44,8 @@ follows 1–3 s after the write, #81; the target-lock status about 1 s). Per-lea | `pending` | – | spec §4.1 rule 4 | daemon | | – | – | | `unread` | – | spec §4.2 | daemon | | – | – | | `asleep` | actions `sleep`, `wake` | status `0x02`: 0 awake, 1 asleep | capture 13; measured 2026-10-24 | | – | – | -| `on_air.active` | – | buffer request on the video node: busy while another reader holds the stream | measured 2026-10-25 | | – | – | -| `on_air.apps` | – | names of processes that hold the node open, from `/proc`, best effort | measured 2026-10-25 | | – | – | +| `on_air.active` | – | buffer request on the video node: busy while another reader holds the stream | daemon (the probe measured on this model 2026-10-25) | | – | – | +| `on_air.apps` | – | names of processes that hold the node open, from `/proc`, best effort | daemon (the probe measured on this model 2026-10-25) | | – | – | | `gimbal.pan` (live `pose`) | – (actions) | query `0x0043` rcv 3: bytes 10–11, i16 yaw, 0.01° | measured 2026-10-31 (#67) | | – | – | | `gimbal.tilt` (live `pose`) | – (actions) | query `0x0043` rcv 3: bytes 8–9, i16 pitch, 0.01° | measured 2026-10-31 (#67) | | – | – | | `gimbal.invert` | framed `0x3b84`, u8 0/1 | `0x0104` byte 11 | capture 05, 17 | | unknown | 3000 | 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 5784475..ed7f520 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 @@ -219,7 +219,9 @@ id the daemon sends the `devices` event first, then one `state` event that carri `available:false` and the last known values; no further state events follow until the device is back. On re-plug the order is the same: `devices`, then `state`. A `port:` entry is removed on unplug: the `devices` event no longer lists it, and no state event follows. -The events of an identity hand-over are in section 5.5. +This removal is always its own `devices` event, sent before any event that adds an entry +under the same id, also when one scan sees a device-number change (5.3). The events of an +identity hand-over are in section 5.5. **Errors:** `{"id","error":{"code","message","rev"?,"applied"?,"failed"?,"supported"?}}`. `message` is always present (English, for logs). `rev` is present whenever the daemon @@ -242,15 +244,19 @@ re-read the device before answering. **Ordering, `rev` and `pending` (normative):** -1. `rev` is per device id. It is 1 for the first state of a new entry, only goes up, and - restarts with the daemon. Every change of a device's published state (values, `pending`, - `unread`, `available`) gets a new `rev`. The state of an unsupported entry never changes, - so its `rev` stays 1. +1. `rev` is per device entry. It is 1 for the first state of a new entry, only goes up while + the entry exists, and restarts with the daemon. An `sn:` entry exists until the daemon + ends. A `port:` entry ends when it is removed; an entry added later under the same + `port:` id is a new entry and starts at 1 again. Clients forget the rev of an id when a + `devices` event no longer lists it. Every change of a device's published state (values, + `pending`, `unread`, `available`) gets a new `rev`. The state of an unsupported entry + never changes, so its `rev` stays 1 while the entry exists. 2. All requests for a supported, available device go through that device's queue and are answered in request order, with these exceptions, which the connection's reader thread - answers at once: `busy`, `unknown_device`; every request for an unavailable device (`get` - from its published state, `set` and `do` with `unavailable`); every request for an - unsupported device (`get` from its fixed state, `set` and `do` with + answers at once: `busy`, `unknown_device`; every request for an unavailable device, and + every request still queued when its device becomes unavailable (5.6): `get` from its + published state, `set` and `do` with `unavailable`; every request for an unsupported + device (`get` from its fixed state, `set` and `do` with `unsupported_device`). A stop (an action the model marks as a stop; on the Tiny 2 Lite `gimbal.stop` and `zoom.stop`) is taken before every queued request and answered when the camera accepted it. Requests for different devices may be answered in any order. @@ -293,7 +299,7 @@ re-read the device before answering. - The worker takes the requests queued for its device as one batch. It runs them in order, then re-reads once the union of the groups they touch (section 8.2), publishes one state, and answers them in order. A camera failure ends the batch; the requests - after it stay queued. + after it stay queued, unless the device became unavailable (5.6). - A `get` never reads the camera. It is answered, in its turn, from the published state: the paths asked for, plus `available`, `pending` and `unread` in full. - A `set` whose patch has exactly one leaf is answered `ok` with the current rev, without @@ -417,8 +423,9 @@ Rules: that was not written since the device last became available. - **`unread`** is part of every state: the leaf paths that were never read since the device last became available (`null`; a group not read while asleep included), and those whose - last read failed (they keep their last value). Their group is read again at its next tier - tick while the camera is awake. + last read failed or was cut short (they keep their last value). Their group is read again + at its next tier tick while the camera is awake; at a wake, and for a `touched` group, 5.7 + ("Wake and retries") says when. - **Undecodable values.** A camera value the model cannot decode is published as the string `raw:` (n = the raw number) and logged once per device and value. It is never turned into `null` or into a neighbouring value. @@ -452,7 +459,7 @@ section 9.3 gives their fate. available daemon: enumeration only pending { : }, unread […] daemon asleep read-only -on_air { active, apps[] } read-only +on_air { active, apps[] } daemon: the on-air probe, read-only gimbal { pan, tilt, invert✎ } pan, tilt: live topic "pose" zoom { factor✎, field_of_view✎ } tracking { mode✎, profile✎, speed✎, target { detected, box } } @@ -611,7 +618,9 @@ factory_reset {confirm} - `preset.save` without `name` names the preset `Preset ` (0.13.2 behaviour). `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`. + slot, and `view.*` with an unknown id, are refused with `conflict`. So are these actions + and `preset.save` while their list (`presets` or `views`) is in `unread`; 5.7 ("Wake and + retries") reads it again. - `power_on.set_pose`, `power_on.use_current` and `power_on.use_preset` rewrite the initial state and keep its tracking mode and `power_on.settings`. - `sleep` and `wake` reply when the camera accepted the command. The state shows `asleep` @@ -701,7 +710,8 @@ A device with a known vendor id and an unregistered product id gets an entry wit `support: unsupported`, `model: unknown`, id `port:`, `serial: null`, `firmware: null`, its `usb` facts, its `video_node`, `available`, and empty `settings`, `actions` and `limits`. Its state is `{"available":true,"pending":{},"unread":[]}` with -`rev` 1, and it never changes: on unplug the entry is removed. It has no worker. The daemon +`rev` 1, and it never changes: on unplug the entry is removed, and a re-plug adds a new +entry (4.1 rule 1). It has no worker. The daemon sends it nothing: no identity query, no status read, no on-air probe, no extension-unit request of any kind. It never opens its video node. The connection's reader thread answers requests for it at once (4.1 rule 2): `get` from the fixed state, `set` and `do` with @@ -712,8 +722,12 @@ requests for it at once (4.1 rule 2): `get` from the fixed state, `set` and `do` - The identity source is a property of the model (section 8.2): the USB serial string when the model has one (read from sysfs through `Transport`, no request), else the model's identity read, else none. -- An identity attempt sends the model's identity read and, where the model has one, its - firmware query. The cap below counts attempts, so it covers both queries. +- An identity attempt sends the model's identity query while no valid serial has been + answered, and, where the model has one, its firmware query while no firmware has been + answered. A series ends at the first attempt after which both have been answered. Where the identity source is the USB serial + string, the serial is read from sysfs once at plug-in, and an attempt is the firmware + query alone; a model with neither query sends no attempt. The cap below counts attempts, + so it covers both queries. - **First series.** The first attempt is sent at plug-in, whether the camera is asleep or awake. Attempts 2 and 3 run with the next two reads of the tier the model names for them (on the Tiny 2 Lite the 5 s tier). That tier is not read while the camera is asleep, so @@ -766,10 +780,20 @@ requests for it at once (4.1 rule 2): `get` from the fixed state, `set` and `do` value; the daemon publishes that state and replies with its rev (4.1 rule 3). An action replies with the current rev. The restart that follows is ordinary hotplug (5.3): the device becomes unavailable, which empties `pending`, and the re-enumerated device - publishes what the camera reports. + publishes what the camera reports. Such a request runs as a batch of its own (4.1 + rule 9): the batch before it ends first, its re-read included. After the camera accepted + the frame, the worker sends nothing more to the device, no request, no stop and no poll, + until the device becomes unavailable or `REQUEST_DEADLINE` (15 s) has passed; then it + works as before. Requests queued meanwhile wait under their own deadline (4.1 rule 9). +- **Unavailable.** When the device becomes unavailable (5.3), its worker ends. A started + request ends as on a camera failure, but without the re-read of 4.1 rule 5: it is + answered `camera` with `applied` and `failed`, without `rev` (nothing was re-read). The + requests still queued are answered as 4.1 rule 2 answers requests for an unavailable + device; for a `port:` entry, which is removed (4.1, Events), they are answered + `unknown_device`. A re-plug starts a new worker with an empty queue. - **Stops.** A stop action jumps the queue (4.1 rule 2). A group read in progress ends at the next frame boundary; its leaves are then listed in `unread`, and the group is read - again at its next tick. + again as 4.2 (`unread`) says. - **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 @@ -788,13 +812,18 @@ requests for it at once (4.1 rule 2): `get` from the fixed state, `set` and `do` starves the 1 s tier. - Polls run group by group. Queued requests are served between groups, never inside one. A group read stops at its first unanswered query; the group's leaves are listed in - `unread`, and the other groups go ahead. + `unread` and read again as 4.2 says, and the other groups go ahead. - An unchanged read produces no new rev and no event. - **Asleep.** If the description has the leaf `asleep`, the worker uses its value. While it is true, only the groups readable while asleep are read. A setting or action with `asleep: ignored` is refused with `asleep`. One with `accepted` or `unknown` is written; if its group cannot be read while asleep, the path stays in `pending` until the first read after wake, and its `settle_ms` counts from that read. +- **Wake and retries.** When `asleep` turns false, the worker reads once every group that + has a leaf in `unread`, `touched` groups included, before the periodic tiers go on; the + next tick of each such group counts from this read. A `touched` group whose read failed or + was cut short is read again at the next wake or with the next read of the 60 s tier, + whichever comes first, until a read succeeds. ### 5.8 Timeouts @@ -1120,9 +1149,11 @@ CI runs this walk-through with the `unknown` fake (section 11). - **Levels.** `captured` and `measured` are the only levels that let a field into a released description. A level belongs to each field, and also to each value of an enum's `values[]` and `writable[]` and to each bound of a number or range: a value or bound - without its own level is not in the description. The model module records the levels as - constants. A test in stage W3 fails if any field, value or bound of any registered model, - `tested` or `untested`, has another level. + without its own level is not in the description. Fields at the level `daemon` are not + camera values and need neither: `available`, `pending`, `unread`, and `on_air.*`, which + the family's on-air probe reads from Linux for every supported model. The model module + records the levels as constants. A test in stage W3 fails if any field, value or bound of + any registered model, `tested` or `untested`, has another level. - **Capture keys.** `capture N` names a recording of the Tiny 2 Lite. A further model's recordings are `capture -N` (`capture tiny3-01`), so keys never collide. - **Fixtures** are single exchanges (request frame and answer), inline in a test or in a @@ -1146,8 +1177,8 @@ CI runs this walk-through with the `unknown` fake (section 11). Tiny 2 Lite: every parity row, section 9). The description holds only backed fields. - **`untested`**: built from an owner's captures (8.7 steps 1–6) without step 7 on a unit by the maintainers. Release notes list it under "Untested models", never as supported. - Package descriptions name tested models only. The description of an untested model has - no action whose `warning` holds `destructive`. + Package descriptions name tested models only. A released description of an untested + model has no action whose `warning` holds `destructive`. - **`unsupported`**: a known vendor id with an unregistered product id (5.4). - The README support table has one row per registered model: display name, USB ids, support level, and the firmware versions the maintainers tested (`–` for an untested @@ -1224,14 +1255,17 @@ Steps, in this order: otherwise the model's own codec (8.2). - Poll plan: groups and tiers taken from the captured idle poll. No tier reads a group more often than the vendor app polls it, the faster tier of a live topic (5.7) - included. + included. One exception: a group with a writable leaf whose captured readback the idle + poll never reads takes the 60 s tier, which 5.7 requires. The on-air probe (level + `daemon`, 8.5) is not part of the vendor app's poll and keeps its 1 s tier. - Paths: an existing path where function, unit and frame are the same as on another model (4.2); `values[]` and `writable[]` for other option sets; a new path or enum value for a new function, with its line in the path dictionary (4.2). This is additive and needs no client change (guarantee A). - Each field, value and bound gets its level in the model's ledger (8.5). One with neither captured nor measured evidence is not in the description. - - No action whose `warning` holds `destructive` while the model is `untested` (8.6). + - No action whose `warning` holds `destructive` in a released `untested` description + (8.6); the maintainers' step 7 adds them. - `asleep` is `unknown` for every field until step 7 measures it. - `settle_ms`: until measured on this model, the largest `settle_ms` of any tested model (today the Tiny 2 Lite's 3000 ms), marked "not measured on this model" in the ledger. @@ -1245,9 +1279,12 @@ Steps, in this order: 7. **Check on the unit.** From step 6 on, a dev build of the model may run on the unit; its transport sends only the model's send table (8.3). The check is `scripts/check-model.sh --device --asleep` (9.4), run by the maintainers (or by an - owner, see "Owners"). Every passing row becomes `measured` with the date; `settle_ms` is - set from the measured lags and `asleep` from the asleep rows; failing fields, values and - bounds leave the description. + owner, see "Owners"). The maintainers' run adds `--destructive`, on a dev build whose + description also holds the destructive actions captured in step 2; these rows and the + restart rows count for `tested` (8.6) like every other row. An owner's run leaves out + `--destructive`, and the rows it skips keep their captured level. Every passing row + becomes `measured` with the date; `settle_ms` is set from the measured lags and `asleep` + from the asleep rows; failing fields, values and bounds leave the description. 8. **Release** as `tested` when the maintainers ran step 7 on a unit, with the firmware versions it was tested on, else as `untested`; with the README row and the release-notes line of 8.6. The CLI needs no change. The widget and the desktop app show the settings @@ -1265,7 +1302,8 @@ M4 is the first model that completes these steps. - **Identity:** the serial query `0x18c8` to receiver `0x0d` answers 14 ASCII characters. It is recorded from the vendor app, which sends it at connect. A read from Linux, asleep and right after plug-in, is camera item CAM-1. Each identity attempt also sends the - firmware query the ledger lists. Attempts 2 and 3 follow the 5 s tier (5.5). No other + firmware query the ledger lists, while the firmware is missing. Attempts 2 and 3 follow + the 5 s tier (5.5). No other identity query is sent: no field uses its answer. - **Live pose:** query `0x0043` to receiver 3, plus the zoom-factor query. The camera's other pose query arms its switch to static after a zone return (#67) and is not in the @@ -1647,8 +1685,11 @@ rewrite runs as W0, then W1 (four parallel tracks A–D), then W2, W3 and W4. filtering; a slow subscriber is disconnected; the 64 KiB request limit; the 64-connection limit; the single-instance lock; the panic policy; `hello` answered by its deadline while a first read is slow; the hand-over event sequence and its rev; requests for unsupported - and unavailable devices answered at once; a `restart` write answered on acceptance; a - device-number change handled as unplug plus plug. + and unavailable devices answered at once; a `restart` write answered on acceptance, and + 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. - **Acceptance with camera:** none in this track (W4 covers the real path). **Track C: client and CLI** @@ -1956,86 +1997,92 @@ own. Decisions A1–A3 (`audio.pickup` merged into `audio.volume`, the hidden `gimbal center` not carried, `gimbal.turn` with `keep`) were confirmed as revision 1 proposed them and are not listed. -| # | Topic | Revision 1 | Revision 2 | Why | Evidence (status) | Source | -| --- | --------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | -| 1 | **REVERSAL** Protocol typing | typed `State`, `Patch`, `Action` with per-setting builders | typed envelopes and descriptions; state, patch and parameters are path → JSON value, checked at run time (4.1, 6.1) | typed builders need a proto release for every new setting | the widget hard-codes 53 request sites (verified); new models bring settings the Lite lacks (unknown in detail until captures) | C1 | -| 2 | "No client change" | literal, for all clients | guarantee A: no code change to proto, client library or CLI while the model fits the existing types, units and constraint forms; descriptions and completions are regenerated as data; a new type or unit is an additive contract change first; hand-made UIs take values and bounds from the description and update only to show new settings (1, 6.2) | a hand-made UI cannot show a setting it does not know | widget code (verified) | C2; the fit condition editor | -| 3 | **REVERSAL** Crates | five: proto, client, device, daemon, CLI | four: the client is a module of `obsbot-proto` (3) | proto and client are always released together | revision 1 dependency graph (verified) | C3 | -| 4 | Schema artefacts | `schema/protocol-2.json` only; completions "from the schema" | protocol schema plus `descriptions/.json`; the CLI embeds the real models' files for completion (3, 6.2) | path completion needs model data and the CLI must not depend on device code | revision 1 contradiction (verified) | C4 | -| 5 | `--device` completion | completed live while the daemon runs | not completed; Tab never contacts the daemon (6.2) | lean; no daemon contact from the shell | none: design choice | writer | -| 6 | Device entry | `id, name, serial, model, firmware, video_node, available, limits{presets,views}` | adds `support`, `usb` with the descriptor facts, `limits` per list path (4.2) | walk-through 2 and onboarding step 1 need them; `devices --report` prints `usb` | walk-throughs 8.4, 8.7 (verified on paper) | C5; `usb` facts writer | -| 7 | Description fields | type, ranges, `readback`, `asleep`, `no_effect` | adds `requires`, `warning`, `default`, `live`, `settle_ms`, `optional`; bounds per type; closed unit list (4.2) | dependencies, restarts, camera defaults and lag must be visible to generic clients | 0.13 refuses exposure time unless manual (verified); the widget resets to UVC defaults (verified) | C6; `warning` on settings writer | -| 8 | Error codes | unknown path = `invalid`; `busy` also during restarts | adds `conflict`; a path not in the description = `unsupported`; `busy` = queue full only (4.1) | a request the current state forbids had no code | 0.13 refuses an empty preset slot (verified) | C7 | -| 9 | **REVERSAL** `epoch` | random `epoch` in the `hello` reply | none; after any reconnect a client rebuilds from `hello` (4.1) | it had no correct use | none: design choice (a reconnect always rebuilds from `hello`) | C8 | -| 10 | Live list | `live:["pose"]`, pose only | live topics named in the description (`pose`, `target`, `auto`); volatile-only changes go only to subscribers (4.1 rule 8) | auto-adjusted values would send an event every second | 0.13 already suppresses them (verified) | C9 | -| 11 | Serial in output | `--json` shortens the id; `devices --show-serial` | `--json` and `watch` carry the full id; human output and logs shorten; `--show-serial` dropped (4.2, 6.2) | a shortened id cannot be passed back; one contract | revision 1 contradiction (verified) | C10; `--show-serial` writer | -| 12 | `--device` matching | name first, then id | full id, a unique serial suffix of ≥ 4 characters, or a unique model key (6.2) | names are gone | none: follows from row 15 | C10 | -| 13 | **REVERSAL** Start mode "not set" | `power_on.start.mode` nullable, `none` = null | the enum value `unset` (4.3) | null now means "not read" | the widget shows "Not set" for an unread start mode (verified) | C11 | -| 14 | **REVERSAL** `gimbal.step` | an action for widget parity | dropped; the widget computes the target and sends `gimbal.move` (4.4) | the camera has no relative move; the base pose was undefined | the widget steps from its last target (verified) | C11 | -| 15 | **REVERSAL** Names | `device.rename`, `names.json` | none; the daemon keeps no file (2, 5.10) | serves two identical cameras nobody has | none: design choice | C12 | -| 16 | `image.reset` | an action for the widget's reset | dropped; the widget sends a patch of `default` values (4.4, 10.3 D) | no camera command; which controls to reset is a product choice | the vendor app resets other controls than the widget (captured, capture 12) | writer | -| 17 | **REVERSAL** Hold timers | "there are no hold timers" | `pending` (path → written value) with a per-leaf `settle_ms`, checked at poll ticks (4.1 rule 4) | without it the old value flickers back after writes; the wait moves into one place | readbacks lag 1–3 s (measured, #81); target lock about 1 s (measured) | D2, J2 | -| 18 | Write path | read back "the affected group"; full read after each command | re-read only the touched groups; a batch shares one re-read; single-leaf coalescing (4.1 rule 9, 5.6) | a slider drag queued behind a full read per step | 0.13.2 reads everything (about 1.4 s) after every command (verified) | D1 | -| 19 | Stops | no rule; replies strictly in order | stops jump the queue and end a running group read (4.1 rule 2) | a stop waited behind a long read | 0.13.2 behaviour (verified) | D8 | -| 20 | Deadlines | "the transport timeout carried over" | named timeouts; 15 s request deadline; bounded outbound queue; at most 64 connections (5.8, 4.1) | unbounded waits; a slow subscriber stalled replies | 0.13.2 timeouts (verified) | D9; 64 connections writer | -| 21 | Asleep writes | `unknown` passed through | `unknown` written; a path whose readback cannot run asleep stays pending until wake (5.7) | the write could never be confirmed | volume and focus readbacks do not answer asleep (0.13.2 behaviour, verified) | D3 | -| 22 | Identity | 3 tries over about 10 s, also after wake; `port:` entries kept | first attempt at plug-in also asleep; attempts 2–3 with the next two reads of a tier the model names; if asleep at plug-in, the first series ends and one series runs from the first wake; serial format check; duplicate → `port:`; `port:` entries removed on unplug (5.5) | bounded, tied to existing reads; an id changes at most once | serial query asleep: unknown (CAM-1) | D4, J4; duplicates and `port:` removal writer; series end editor | -| 23 | Unknown devices | undefined; "read model and serial" for every device | listed `unsupported`, nothing sent, `devices --report` (5.4, 8.4) | any frame to an unknown device is a probe | a blind query scan broke the query channel once (measured) | D5 | -| 24 | **REVERSAL** Hotplug | a udev monitor | a sysfs scan once a second, no libudev, no netlink (5.3) | no C dependency; 0.13 already rescans every second | 0.13.2 daemon (verified) | D6 | -| 25 | Start-up | undefined | `hello` answered after the first enumeration; a device announced after its first read (5.2) | a socket-activated `status` saw no camera | revision 1 gap (verified) | D7 | -| 26 | Single instance | lock at `$XDG_RUNTIME_DIR/obsbot4linux.lock`; turn stops on shutdown | lock at `.lock` for every daemon; a clean shutdown stops a turn best effort; a crash leaves the camera (5.9) | test daemons collided; the 0.13 daemon uses the same lock name | 0.13.2 lock (verified) | D10 | -| 27 | **REVERSAL** Hardening | `NoNewPrivileges`, `ProtectSystem`, `DeviceAllow`, tested on Arch and Ubuntu 24.04 | `NoNewPrivileges=yes` only; the rest after 1.0 (5.10) | independent of the rebuild and costly to accept | in user units these lines imply `PrivateUsers` (verified, systemd documentation); no run on the real unit yet (verified) | D11 | -| 28 | **REVERSAL** Mutation tests | a stage-5 gate on device and proto | one pass over the carried code in stage G; no 1.0 gate (10.2, 11) | they test carried code, which moves first | seven sign-flip mutants survive in the carried code (verified) | D11 | -| 29 | Daemon start | "nothing needs re-enabling" | socket only; packages enable nothing; exit 4 prints the enable line; an older daemon is detected (5.10, 12.2) | default widget setups run the daemon as a child of `watch` with the socket disabled | the owner's desktop (verified) | D12 | -| 30 | 0.13 state files | the daemon deletes `state.json`; the guide promised an import | the daemon touches no file; the migration notes say the directory can be deleted (12.2) | "keeps no file at all"; no import exists | revision 1 contradiction (verified) | writer (C12) | -| 31 | Never-send | a deny-list of two ids | a per-model send table checked in the transport, plus refused classes; no raw path (8.3) | a deny-list protects only against the ids it names | verified | E1 | -| 32 | Public text | text that 8.5 does not allow | only what 8.5 allows; evidence by level and opaque key | the repository is public | verified | E2, J8 | -| 33 | **REVERSAL** Fakes | `--fake N`: N identical Tiny 2 Lites with test-only actions | `fake-a`, `fake-b` (deliberately different), `unknown`; test control on a second socket (5.12) | mixed models and unknown devices were never tested | revision 1's fakes cannot exercise mixed models or an unknown device (verified) | F1–F3 | -| 34 | **REVERSAL** M4 and the catalogue | M4 = TOML catalogue plus rudimentary support for further models without captures | no catalogue; models only with a unit and captures; M4 = the first captured second model (8.7, 13) | "common commands" cannot be assumed | none: design choice (a model only with a unit and captures); identity-query loops linked to USB drops on a Tiny 3 (one third-party report, unverified) | B3, B4, B5, J7 | -| 35 | Tiny 3 family | – | two models, two modules; nothing built before a unit is at hand; every device fact unknown until captures (8.9) | nothing is known about either device | none: design choice; every device fact unknown until captures | B5, J1 | -| 36 | Registry key | "its USB id" | USB (vendor id, product id) pairs, several per model; a vendor table of measured ids only (8.1) | a model may have several product ids | none: design choice | B6 | -| 37 | Extension unit | unit 2 hard-coded | found by GUID in the USB descriptor, read-only (8.3) | no per-model constant, no request | the descriptor holds unit and GUID (captured, capture 01) | B7 | -| 38 | Model seam | one sentence; the daemon section named Lite ids and tiers | three layers in `obsbot-device`; a path-keyed `trait Camera`; the daemon names no id (3.1, 5, 8.2) | per-model data plus per-model flows; a daemon that knows the Lite cannot run another model | about 83 family-generic and 64 Lite-specific knowledge items in the carried code (verified count) | B1, B2 | -| 39 | Live pose query | the poll tier named the query that arms the zone switch | the model uses `0x0043`; the daemon names no query (8.8) | that query arms the switch to static after a zone return | #67 (measured) | B2 (writer) | -| 40 | **REVERSAL** (partial) Delivery | a long-lived `rebuild` branch, five serial stages | stage G first (released as 0.14.0 from `main`); W0, four parallel tracks, W2, W3, W4 (10) | 64 % of the lines are carried; fixes would land twice; packaging first ran on the tag | line count (verified) | H1, H2, J5 | -| 41 | CI on `rebuild` | assumed | `ci.yml` and a ruleset for `rebuild` before its first PR (10.1) | PRs into `rebuild` ran no CI | GitHub settings (verified) | J5 | -| 42 | Widget channel | undefined; `watch` relayed requests in 0.13 | the widget speaks protocol 2 on the socket; `watch` prints events; `relay` as fallback (6.2, 10.3 D) | one contract; no protocol-1 adapter | a socket type in the shell's runtime: unknown | H3 | -| 43 | Release day | widget and tool the same day | widget 0.28.0 with an upper bound first; a release candidate; a "done" list (10.6) | widget 0.27 would accept 1.0 and fail at run time | widget start-up check (verified) | H4 | -| 44 | Milestones | 0.14.0–0.16.0 on `main` as planned on 2026-10-31 | a proposal: 0.14.0 = groundwork, #73 and #89 into 1.0, 0.15.0 and 0.16.0 after 1.0 (10.5) | the groundwork release carries the carried-code fixes | none: planning choice | H5, J6 | -| 45 | Desktop app dependency | git tag; shell starts after stage 1 | a local checkout before `v1.0.0`, a git tag ≥ `v1.0.0` after; SemVer rules (12.4, 12.5) | no tag exists before 1.0 | no tag exists before `v1.0.0` (verified) | H6; checkout writer | -| 46 | Evidence rule | "every function of 0.13 stays" | only backed fields are offered; unbacked Lite fields are checked or become named exceptions (9.3) | the two rules collided for fields without backing | ledger levels (verified) | J3 | -| 47 | Parity script | `check-parity.sh`, set → read back → restore for every row | `check-model.sh` from the description, `--device`, destructive rows last, restore by trap (9.4) | not every row can be restored; a second model has no 0.13 rows | 0.13's readback script restores incompletely (verified) | writer | -| 48 | `image.gamma` | a path, "incl. gamma" | removed; PX-2 | the camera has no gamma control | the recorded UVC descriptor (captured) | writer | -| 49 | Exposure compensation | `image.backlight_compensation`; "a guess" | `exposure.compensation`, the raw value 0–18, default 9; EV only after U-20 | the vendor app's exposure compensation writes this control; its scale is inferred | captured, capture 12; the 1/3 EV step unverified | writer | -| 50 | Anti-flicker | `auto` writable | `auto` read-only; no `default` (4.3); the value `auto` itself is U-25 | the kernel driver offers 0–2 on this camera | kernel driver (verified); the camera reporting `auto`: never recorded (U-25) | writer | -| 51 | Sleep background | `black\|image` | values add `own_image` (read-only) | the camera reports it after an upload | captured (capture 18) | writer | -| 52 | `gimbal.move` | `pan` and `tilt` required | either may be missing; the camera keeps that axis (4.4) | a pan-only move pulled a stale tilt | 0.13.2 behaviour (verified); the keep value (captured) | writer | -| 53 | Current pose | only `preset.save` and `view.save` named the live pose | every pose-taking action uses the live pose and is refused while asleep until CAM-22 (4.4) | 0.13 used a remembered or V4L2 pose | 0.13.2 code (verified) | writer | -| 54 | Target box | layout an open point; read every second | layout settled; box changes go to the live topic `target` (4.3) | the layout was checked over the preview | #71 (measured) | writer, C9 | -| 55 | Preset read | in the 60 s tier | at plug-in and after the actions that change presets (`touched`, 8.8) | the read writes to the camera; the vendor app never does | captured (no such write in any recording) | writer | -| 56 | `do wake` | replies once the status reports awake | replies on acceptance; the state shows `asleep` (4.4) | an unbounded wait in the worker | wake time unknown (CAM-3) | writer | -| 57 | Null and unread | `null` only for `nullable` | `unread` leaf paths, `raw:` values, `readback:false` lifetime per availability (4.2) | "not set" and "not read" were indistinguishable | 0.13 widget shows "Not set" for unread values (verified) | writer | -| 58 | `audio.enabled` | no readback; `busy` during the restart after "off" | readback from the USB audio interface; restart on both values; reply on acceptance without a re-read, the restart is hotplug (4.3, 5.6) | the restart is hotplug; a sysfs fact is camera state | the interface follows the toggle (measured); restart on both values (measured) | writer, C7 | -| 59 | Value details | zoom-move speed a raw integer; exposure time "steps in 100 µs" | zoom-move speed `slow\|medium\|fast`; no step promise for exposure time; zoom ratio precision 0.1; `sleep.auto.seconds` 1–32767; values rounded at the source | only known values are offered; the readback limits precision | 0.13.2 code (verified) | writer | -| 60 | Write order | anti-flicker before manual, citing a 0.13 function | `requires` paths first; tracking mode before framing; the anti-flicker order is CAM-13 (8.8) | the cited function has no such step | 0.13.2 code (verified) | writer | -| 61 | On air | `{active, apps[]}` | one sample; a client's own preview counts; kernel note (4.3) | 0.13 read the two at different moments | 0.13.2 code and kernel source (verified) | writer | -| 62 | `available` | from hotplug | only from enumeration; a failed read never flips it (5.3) | 0.13 reported "camera gone" for one failed tick | 0.13.2 daemon (verified) | writer | -| 63 | Poll intervals | carried from 0.13 | counted from the end of the previous read (5.7) | a slow read starved the 1 s tier | 0.13.2 daemon (verified) | writer | -| 64 | `view.save` name | optional | required, as in 0.13 (4.4) | 0.13 requires it; the camera's default is not recorded | 0.13.2 code (verified) | writer | -| 65 | Untested models | marked UNTESTED in docs | a `support` field; no destructive actions; never called supported (8.6) | a field was missing | none: design choice | C5, writer | -| 66 | `--json` errors | not defined for usage errors | `{"error":{"code","message"}}` on stdout, exit 2 (6.2) | clap printed text | 0.13.2 CLI (verified) | writer | -| 67 | Licence note | names the project the code came from | a general rule: the EUPL note follows the code into every file (3.3) | split and copied code must keep it | none: licence obligation | writer | -| 68 | Unsupported devices on the socket | undefined | `unsupported_device` for `set` and `do`; the reader thread answers requests for unsupported and unavailable devices at once; the CLI refuses before sending; CLI-only error codes with fixed exits (4.1, 5.4, 6.2) | one code per situation; no worker for a device that receives nothing | none: design choice | editor | -| 69 | `hello` deadline | `hello` after the first enumeration | `hello` after the first read attempts, at the latest after 15 s (`REQUEST_DEADLINE`); late devices `unread` (4.1, 5.2) | a slow cold read must not make the CLI report a lost daemon | cold read time unknown (CAM-10) | editor (D7) | -| 70 | Hand-over events | one `devices` event with `replaced_by` | two `devices` events, then a `state` event; rev continues above both entries; `pending` and `unread` carry over (5.5) | clients compare revs per id; the old rule left the sequence open | none: design choice | editor | -| 71 | `warning` | one value | a list; `factory_reset` holds `destructive` and `restart`; every `restart` write replies on acceptance (4.2, 5.6) | the daemon must know which writes restart the camera | factory reset and microphone restart (measured) | editor (C6, C7) | -| 72 | Zoom kind | `kind`, `optical_max` on `zoom.factor` | dropped; added when a model with optical zoom arrives (4.2) | no 1.0 model uses them | none: design choice | editor | -| 73 | Pending reads | – | no extra reads while a path is pending; a writable leaf with a readback is in a periodic group (5.6, 5.7) | decision D2 rules out an extra polling loop | none: design choice | editor (D2) | -| 74 | Evidence levels | per field | per field, value and bound; the level test covers untested models too (8.5) | the Tiny 2 Lite needed evidence per value (U-12, U-18, U-25, U-26) | field ledger (verified) | editor | -| 75 | Owners | a model from an owner's captures, no procedure | owners record steps 1–3, send recordings privately, may run step 7 on a dev build; their results count as `measured`; only the maintainers' step 7 makes `tested` (8.4, 8.7) | walk-through 2 ended without a route for users who own a unit | none: design choice | editor (B4, G) | -| 76 | Model query | sent with identity | not sent: no field uses its answer (8.8) | send only what is used | none: design choice | editor | -| 77 | After 1.0 | – | process, versions and releases after 1.0 (10.7) | section 10 covered only the rebuild | none: design choice | editor | -| 78 | Paths across models | "one meaning per path" | a path dictionary in `docs/protocol-2.md` with a naming rule (4.2) | reuse of a path could not be checked | none: design choice | editor | -| 79 | Re-enumeration | sysfs entry gone | USB path plus device number; a change is unplug plus plug (5.3) | a re-enumeration within one scan interval was invisible | none: design choice | editor | -| 80 | Check script | set → read back → restore | adds lag, asleep and confirmation rows; `settle_ms` and `asleep` come from it (9.4) | step 7 set these without a procedure | none: design choice | editor (D2) | -| 81 | Extension unit per model | one family selector set and GUID | GUID and selectors in the registry entry; the class check uses them (8.2, 8.3) | a second model may use another unit or other selectors | unknown until captures | editor (B7) | +| # | Topic | Revision 1 | Revision 2 | Why | Evidence (status) | Source | +| --- | ------------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | +| 1 | **REVERSAL** Protocol typing | typed `State`, `Patch`, `Action` with per-setting builders | typed envelopes and descriptions; state, patch and parameters are path → JSON value, checked at run time (4.1, 6.1) | typed builders need a proto release for every new setting | the widget hard-codes 53 request sites (verified); new models bring settings the Lite lacks (unknown in detail until captures) | C1 | +| 2 | "No client change" | literal, for all clients | guarantee A: no code change to proto, client library or CLI while the model fits the existing types, units and constraint forms; descriptions and completions are regenerated as data; a new type or unit is an additive contract change first; hand-made UIs take values and bounds from the description and update only to show new settings (1, 6.2) | a hand-made UI cannot show a setting it does not know | widget code (verified) | C2; the fit condition editor | +| 3 | **REVERSAL** Crates | five: proto, client, device, daemon, CLI | four: the client is a module of `obsbot-proto` (3) | proto and client are always released together | revision 1 dependency graph (verified) | C3 | +| 4 | Schema artefacts | `schema/protocol-2.json` only; completions "from the schema" | protocol schema plus `descriptions/.json`; the CLI embeds the real models' files for completion (3, 6.2) | path completion needs model data and the CLI must not depend on device code | revision 1 contradiction (verified) | C4 | +| 5 | `--device` completion | completed live while the daemon runs | not completed; Tab never contacts the daemon (6.2) | lean; no daemon contact from the shell | none: design choice | writer | +| 6 | Device entry | `id, name, serial, model, firmware, video_node, available, limits{presets,views}` | adds `support`, `usb` with the descriptor facts, `limits` per list path (4.2) | walk-through 2 and onboarding step 1 need them; `devices --report` prints `usb` | walk-throughs 8.4, 8.7 (verified on paper) | C5; `usb` facts writer | +| 7 | Description fields | type, ranges, `readback`, `asleep`, `no_effect` | adds `requires`, `warning`, `default`, `live`, `settle_ms`, `optional`; bounds per type; closed unit list (4.2) | dependencies, restarts, camera defaults and lag must be visible to generic clients | 0.13 refuses exposure time unless manual (verified); the widget resets to UVC defaults (verified) | C6; `warning` on settings writer | +| 8 | Error codes | unknown path = `invalid`; `busy` also during restarts | adds `conflict`; a path not in the description = `unsupported`; `busy` = queue full only (4.1) | a request the current state forbids had no code | 0.13 refuses an empty preset slot (verified) | C7 | +| 9 | **REVERSAL** `epoch` | random `epoch` in the `hello` reply | none; after any reconnect a client rebuilds from `hello` (4.1) | it had no correct use | none: design choice (a reconnect always rebuilds from `hello`) | C8 | +| 10 | Live list | `live:["pose"]`, pose only | live topics named in the description (`pose`, `target`, `auto`); volatile-only changes go only to subscribers (4.1 rule 8) | auto-adjusted values would send an event every second | 0.13 already suppresses them (verified) | C9 | +| 11 | Serial in output | `--json` shortens the id; `devices --show-serial` | `--json` and `watch` carry the full id; human output and logs shorten; `--show-serial` dropped (4.2, 6.2) | a shortened id cannot be passed back; one contract | revision 1 contradiction (verified) | C10; `--show-serial` writer | +| 12 | `--device` matching | name first, then id | full id, a unique serial suffix of ≥ 4 characters, or a unique model key (6.2) | names are gone | none: follows from row 15 | C10 | +| 13 | **REVERSAL** Start mode "not set" | `power_on.start.mode` nullable, `none` = null | the enum value `unset` (4.3) | null now means "not read" | the widget shows "Not set" for an unread start mode (verified) | C11 | +| 14 | **REVERSAL** `gimbal.step` | an action for widget parity | dropped; the widget computes the target and sends `gimbal.move` (4.4) | the camera has no relative move; the base pose was undefined | the widget steps from its last target (verified) | C11 | +| 15 | **REVERSAL** Names | `device.rename`, `names.json` | none; the daemon keeps no file (2, 5.10) | serves two identical cameras nobody has | none: design choice | C12 | +| 16 | `image.reset` | an action for the widget's reset | dropped; the widget sends a patch of `default` values (4.4, 10.3 D) | no camera command; which controls to reset is a product choice | the vendor app resets other controls than the widget (captured, capture 12) | writer | +| 17 | **REVERSAL** Hold timers | "there are no hold timers" | `pending` (path → written value) with a per-leaf `settle_ms`, checked at poll ticks (4.1 rule 4) | without it the old value flickers back after writes; the wait moves into one place | readbacks lag 1–3 s (measured, #81); target lock about 1 s (measured) | D2, J2 | +| 18 | Write path | read back "the affected group"; full read after each command | re-read only the touched groups; a batch shares one re-read; single-leaf coalescing (4.1 rule 9, 5.6) | a slider drag queued behind a full read per step | 0.13.2 reads everything (about 1.4 s) after every command (verified) | D1 | +| 19 | Stops | no rule; replies strictly in order | stops jump the queue and end a running group read (4.1 rule 2) | a stop waited behind a long read | 0.13.2 behaviour (verified) | D8 | +| 20 | Deadlines | "the transport timeout carried over" | named timeouts; 15 s request deadline; bounded outbound queue; at most 64 connections (5.8, 4.1) | unbounded waits; a slow subscriber stalled replies | 0.13.2 timeouts (verified) | D9; 64 connections writer | +| 21 | Asleep writes | `unknown` passed through | `unknown` written; a path whose readback cannot run asleep stays pending until wake (5.7) | the write could never be confirmed | volume and focus readbacks do not answer asleep (0.13.2 behaviour, verified) | D3 | +| 22 | Identity | 3 tries over about 10 s, also after wake; `port:` entries kept | first attempt at plug-in also asleep; attempts 2–3 with the next two reads of a tier the model names; if asleep at plug-in, the first series ends and one series runs from the first wake; serial format check; duplicate → `port:`; `port:` entries removed on unplug; a series ends once serial and firmware are answered, and an attempt sends only the missing queries (5.5) | bounded, tied to existing reads; an id changes at most once | serial query asleep: unknown (CAM-1) | D4, J4; duplicates and `port:` removal writer; series end editor | +| 23 | Unknown devices | undefined; "read model and serial" for every device | listed `unsupported`, nothing sent, `devices --report` (5.4, 8.4) | any frame to an unknown device is a probe | a blind query scan broke the query channel once (measured) | D5 | +| 24 | **REVERSAL** Hotplug | a udev monitor | a sysfs scan once a second, no libudev, no netlink (5.3) | no C dependency; 0.13 already rescans every second | 0.13.2 daemon (verified) | D6 | +| 25 | Start-up | undefined | `hello` answered after the first enumeration; a device announced after its first read (5.2) | a socket-activated `status` saw no camera | revision 1 gap (verified) | D7 | +| 26 | Single instance | lock at `$XDG_RUNTIME_DIR/obsbot4linux.lock`; turn stops on shutdown | lock at `.lock` for every daemon; a clean shutdown stops a turn best effort; a crash leaves the camera (5.9) | test daemons collided; the 0.13 daemon uses the same lock name | 0.13.2 lock (verified) | D10 | +| 27 | **REVERSAL** Hardening | `NoNewPrivileges`, `ProtectSystem`, `DeviceAllow`, tested on Arch and Ubuntu 24.04 | `NoNewPrivileges=yes` only; the rest after 1.0 (5.10) | independent of the rebuild and costly to accept | in user units these lines imply `PrivateUsers` (verified, systemd documentation); no run on the real unit yet (verified) | D11 | +| 28 | **REVERSAL** Mutation tests | a stage-5 gate on device and proto | one pass over the carried code in stage G; no 1.0 gate (10.2, 11) | they test carried code, which moves first | seven sign-flip mutants survive in the carried code (verified) | D11 | +| 29 | Daemon start | "nothing needs re-enabling" | socket only; packages enable nothing; exit 4 prints the enable line; an older daemon is detected (5.10, 12.2) | default widget setups run the daemon as a child of `watch` with the socket disabled | the owner's desktop (verified) | D12 | +| 30 | 0.13 state files | the daemon deletes `state.json`; the guide promised an import | the daemon touches no file; the migration notes say the directory can be deleted (12.2) | "keeps no file at all"; no import exists | revision 1 contradiction (verified) | writer (C12) | +| 31 | Never-send | a deny-list of two ids | a per-model send table checked in the transport, plus refused classes; no raw path (8.3) | a deny-list protects only against the ids it names | verified | E1 | +| 32 | Public text | text that 8.5 does not allow | only what 8.5 allows; evidence by level and opaque key | the repository is public | verified | E2, J8 | +| 33 | **REVERSAL** Fakes | `--fake N`: N identical Tiny 2 Lites with test-only actions | `fake-a`, `fake-b` (deliberately different), `unknown`; test control on a second socket (5.12) | mixed models and unknown devices were never tested | revision 1's fakes cannot exercise mixed models or an unknown device (verified) | F1–F3 | +| 34 | **REVERSAL** M4 and the catalogue | M4 = TOML catalogue plus rudimentary support for further models without captures | no catalogue; models only with a unit and captures; M4 = the first captured second model (8.7, 13) | "common commands" cannot be assumed | none: design choice (a model only with a unit and captures); identity-query loops linked to USB drops on a Tiny 3 (one third-party report, unverified) | B3, B4, B5, J7 | +| 35 | Tiny 3 family | – | two models, two modules; nothing built before a unit is at hand; every device fact unknown until captures (8.9) | nothing is known about either device | none: design choice; every device fact unknown until captures | B5, J1 | +| 36 | Registry key | "its USB id" | USB (vendor id, product id) pairs, several per model; a vendor table of measured ids only (8.1) | a model may have several product ids | none: design choice | B6 | +| 37 | Extension unit | unit 2 hard-coded | found by GUID in the USB descriptor, read-only (8.3) | no per-model constant, no request | the descriptor holds unit and GUID (captured, capture 01) | B7 | +| 38 | Model seam | one sentence; the daemon section named Lite ids and tiers | three layers in `obsbot-device`; a path-keyed `trait Camera`; the daemon names no id (3.1, 5, 8.2) | per-model data plus per-model flows; a daemon that knows the Lite cannot run another model | about 83 family-generic and 64 Lite-specific knowledge items in the carried code (verified count) | B1, B2 | +| 39 | Live pose query | the poll tier named the query that arms the zone switch | the model uses `0x0043`; the daemon names no query (8.8) | that query arms the switch to static after a zone return | #67 (measured) | B2 (writer) | +| 40 | **REVERSAL** (partial) Delivery | a long-lived `rebuild` branch, five serial stages | stage G first (released as 0.14.0 from `main`); W0, four parallel tracks, W2, W3, W4 (10) | 64 % of the lines are carried; fixes would land twice; packaging first ran on the tag | line count (verified) | H1, H2, J5 | +| 41 | CI on `rebuild` | assumed | `ci.yml` and a ruleset for `rebuild` before its first PR (10.1) | PRs into `rebuild` ran no CI | GitHub settings (verified) | J5 | +| 42 | Widget channel | undefined; `watch` relayed requests in 0.13 | the widget speaks protocol 2 on the socket; `watch` prints events; `relay` as fallback (6.2, 10.3 D) | one contract; no protocol-1 adapter | a socket type in the shell's runtime: unknown | H3 | +| 43 | Release day | widget and tool the same day | widget 0.28.0 with an upper bound first; a release candidate; a "done" list (10.6) | widget 0.27 would accept 1.0 and fail at run time | widget start-up check (verified) | H4 | +| 44 | Milestones | 0.14.0–0.16.0 on `main` as planned on 2026-10-31 | a proposal: 0.14.0 = groundwork, #73 and #89 into 1.0, 0.15.0 and 0.16.0 after 1.0 (10.5) | the groundwork release carries the carried-code fixes | none: planning choice | H5, J6 | +| 45 | Desktop app dependency | git tag; shell starts after stage 1 | a local checkout before `v1.0.0`, a git tag ≥ `v1.0.0` after; SemVer rules (12.4, 12.5) | no tag exists before 1.0 | no tag exists before `v1.0.0` (verified) | H6; checkout writer | +| 46 | Evidence rule | "every function of 0.13 stays" | only backed fields are offered; unbacked Lite fields are checked or become named exceptions (9.3) | the two rules collided for fields without backing | ledger levels (verified) | J3 | +| 47 | Parity script | `check-parity.sh`, set → read back → restore for every row | `check-model.sh` from the description, `--device`, destructive rows last, restore by trap (9.4) | not every row can be restored; a second model has no 0.13 rows | 0.13's readback script restores incompletely (verified) | writer | +| 48 | `image.gamma` | a path, "incl. gamma" | removed; PX-2 | the camera has no gamma control | the recorded UVC descriptor (captured) | writer | +| 49 | Exposure compensation | `image.backlight_compensation`; "a guess" | `exposure.compensation`, the raw value 0–18, default 9; EV only after U-20 | the vendor app's exposure compensation writes this control; its scale is inferred | captured, capture 12; the 1/3 EV step unverified | writer | +| 50 | Anti-flicker | `auto` writable | `auto` read-only; no `default` (4.3); the value `auto` itself is U-25 | the kernel driver offers 0–2 on this camera | kernel driver (verified); the camera reporting `auto`: never recorded (U-25) | writer | +| 51 | Sleep background | `black\|image` | values add `own_image` (read-only) | the camera reports it after an upload | captured (capture 18) | writer | +| 52 | `gimbal.move` | `pan` and `tilt` required | either may be missing; the camera keeps that axis (4.4) | a pan-only move pulled a stale tilt | 0.13.2 behaviour (verified); the keep value (captured) | writer | +| 53 | Current pose | only `preset.save` and `view.save` named the live pose | every pose-taking action uses the live pose and is refused while asleep until CAM-22 (4.4) | 0.13 used a remembered or V4L2 pose | 0.13.2 code (verified) | writer | +| 54 | Target box | layout an open point; read every second | layout settled; box changes go to the live topic `target` (4.3) | the layout was checked over the preview | #71 (measured) | writer, C9 | +| 55 | Preset read | in the 60 s tier | at plug-in and after the actions that change presets (`touched`, 8.8) | the read writes to the camera; the vendor app never does | captured (no such write in any recording) | writer | +| 56 | `do wake` | replies once the status reports awake | replies on acceptance; the state shows `asleep` (4.4) | an unbounded wait in the worker | wake time unknown (CAM-3) | writer | +| 57 | Null and unread | `null` only for `nullable` | `unread` leaf paths, `raw:` values, `readback:false` lifetime per availability (4.2) | "not set" and "not read" were indistinguishable | 0.13 widget shows "Not set" for unread values (verified) | writer | +| 58 | `audio.enabled` | no readback; `busy` during the restart after "off" | readback from the USB audio interface; restart on both values; reply on acceptance without a re-read, the restart is hotplug (4.3, 5.6) | the restart is hotplug; a sysfs fact is camera state | the interface follows the toggle (measured); restart on both values (measured) | writer, C7 | +| 59 | Value details | zoom-move speed a raw integer; exposure time "steps in 100 µs" | zoom-move speed `slow\|medium\|fast`; no step promise for exposure time; zoom ratio precision 0.1; `sleep.auto.seconds` 1–32767; values rounded at the source | only known values are offered; the readback limits precision | 0.13.2 code (verified) | writer | +| 60 | Write order | anti-flicker before manual, citing a 0.13 function | `requires` paths first; tracking mode before framing; the anti-flicker order is CAM-13 (8.8) | the cited function has no such step | 0.13.2 code (verified) | writer | +| 61 | On air | `{active, apps[]}` | one sample; a client's own preview counts; kernel note (4.3) | 0.13 read the two at different moments | 0.13.2 code and kernel source (verified) | writer | +| 62 | `available` | from hotplug | only from enumeration; a failed read never flips it (5.3) | 0.13 reported "camera gone" for one failed tick | 0.13.2 daemon (verified) | writer | +| 63 | Poll intervals | carried from 0.13 | counted from the end of the previous read (5.7) | a slow read starved the 1 s tier | 0.13.2 daemon (verified) | writer | +| 64 | `view.save` name | optional | required, as in 0.13 (4.4) | 0.13 requires it; the camera's default is not recorded | 0.13.2 code (verified) | writer | +| 65 | Untested models | marked UNTESTED in docs | a `support` field; no destructive actions; never called supported (8.6) | a field was missing | none: design choice | C5, writer | +| 66 | `--json` errors | not defined for usage errors | `{"error":{"code","message"}}` on stdout, exit 2 (6.2) | clap printed text | 0.13.2 CLI (verified) | writer | +| 67 | Licence note | names the project the code came from | a general rule: the EUPL note follows the code into every file (3.3) | split and copied code must keep it | none: licence obligation | writer | +| 68 | Unsupported devices on the socket | undefined | `unsupported_device` for `set` and `do`; the reader thread answers requests for unsupported and unavailable devices at once; the CLI refuses before sending; CLI-only error codes with fixed exits (4.1, 5.4, 6.2) | one code per situation; no worker for a device that receives nothing | none: design choice | editor | +| 69 | `hello` deadline | `hello` after the first enumeration | `hello` after the first read attempts, at the latest after 15 s (`REQUEST_DEADLINE`); late devices `unread` (4.1, 5.2) | a slow cold read must not make the CLI report a lost daemon | cold read time unknown (CAM-10) | editor (D7) | +| 70 | Hand-over events | one `devices` event with `replaced_by` | two `devices` events, then a `state` event; rev continues above both entries; `pending` and `unread` carry over (5.5) | clients compare revs per id; the old rule left the sequence open | none: design choice | editor | +| 71 | `warning` | one value | a list; `factory_reset` holds `destructive` and `restart`; every `restart` write replies on acceptance (4.2, 5.6) | the daemon must know which writes restart the camera | factory reset and microphone restart (measured) | editor (C6, C7) | +| 72 | Zoom kind | `kind`, `optical_max` on `zoom.factor` | dropped; added when a model with optical zoom arrives (4.2) | no 1.0 model uses them | none: design choice | editor | +| 73 | Pending reads | – | no extra reads while a path is pending; a writable leaf with a readback is in a periodic group (5.6, 5.7) | decision D2 rules out an extra polling loop | none: design choice | editor (D2) | +| 74 | Evidence levels | per field | per field, value and bound; the level test covers untested models too (8.5) | the Tiny 2 Lite needed evidence per value (U-12, U-18, U-25, U-26) | field ledger (verified) | editor | +| 75 | Owners | a model from an owner's captures, no procedure | owners record steps 1–3, send recordings privately, may run step 7 on a dev build; their results count as `measured`; only the maintainers' step 7 makes `tested` (8.4, 8.7) | walk-through 2 ended without a route for users who own a unit | none: design choice | editor (B4, G) | +| 76 | Model query | sent with identity | not sent: no field uses its answer (8.8) | send only what is used | none: design choice | editor | +| 77 | After 1.0 | – | process, versions and releases after 1.0 (10.7) | section 10 covered only the rebuild | none: design choice | editor | +| 78 | Paths across models | "one meaning per path" | a path dictionary in `docs/protocol-2.md` with a naming rule (4.2) | reuse of a path could not be checked | none: design choice | editor | +| 79 | Re-enumeration | sysfs entry gone | USB path plus device number; a change is unplug plus plug (5.3) | a re-enumeration within one scan interval was invisible | none: design choice | editor | +| 80 | Check script | set → read back → restore | adds lag, asleep and confirmation rows; `settle_ms` and `asleep` come from it (9.4) | step 7 set these without a procedure | none: design choice | editor (D2) | +| 81 | Extension unit per model | one family selector set and GUID | GUID and selectors in the registry entry; the class check uses them (8.2, 8.3) | a second model may use another unit or other selectors | unknown until captures | editor (B7) | +| 82 | Worker end | – | when a device becomes unavailable its worker ends: a started request gets `camera` without `rev`, queued requests are answered as for an unavailable device (`unknown_device` for a removed `port:` entry), a re-plug starts with an empty queue; a `restart` request runs as its own batch, and after its frame the worker sends nothing, stops included, until the device goes, at most 15 s (5.6) | the fate of the queue at unplug and during a restart was open | none: design choice | editor | +| 83 | `rev` of a re-added `port:` id | – | `rev` is per entry: a `port:` entry added again starts at 1; its removal is always its own `devices` event; clients forget an id's rev when a `devices` event drops it (4.1) | clients that kept the highest rev per id dropped the new entry's states | none: design choice | editor (row 70) | +| 84 | Reads at wake | – | at wake every group with an `unread` leaf is read once, `touched` groups included; a failed `touched` read is retried at the next wake or 60 s read; slot and id actions are refused with `conflict` while their list is unread (4.4, 5.7) | a camera asleep at start-up left `presets` unread until a preset action | none: design choice | editor (D3) | +| 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) | From 41830b36147ab08bcdccacb832ae6222ddef54a0 Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 07:53:05 +0100 Subject: [PATCH 05/27] Add the implementation plan for the groundwork stage Eight pull requests into rebuild: guards in the 0.13 layout (byte guard, CLI help guard, package contents check, test-name list, move check), the cargo workspace, three parallel moves into family, transport and models/tiny2lite, the camera.rs split, the final module paths, and one mutation pass. No behaviour change. Co-Authored-By: Claude Opus 5.5 --- .../2026-11-13-obsbot4linux-groundwork.md | 2516 +++++++++++++++++ 1 file changed, 2516 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-13-obsbot4linux-groundwork.md diff --git a/docs/superpowers/plans/2026-11-13-obsbot4linux-groundwork.md b/docs/superpowers/plans/2026-11-13-obsbot4linux-groundwork.md new file mode 100644 index 0000000..9113985 --- /dev/null +++ b/docs/superpowers/plans/2026-11-13-obsbot4linux-groundwork.md @@ -0,0 +1,2516 @@ +# obsbot4linux Groundwork (Stage G) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the 0.13 single crate into the cargo workspace of the 1.0 design, with the carried +modules at their final paths in `crates/obsbot-device` and the 0.13 daemon and CLI unchanged on +top of them, without changing one byte sent to the camera or one line of CLI output. + +**Architecture:** Pure moves on the branch `rebuild`, in eight pull requests. Task 1 adds guards +to the 0.13 layout (a byte guard over every command builder, a CLI help guard, a package +contents check, a test-name list and a move check). Task 2 makes the workspace with the files +moved flat. Tasks 3 to 5 move the carried modules into `family`, `transport` and +`models/tiny2lite` in parallel, keeping the old module paths alive through re-exports. Task 6 +splits `camera.rs`, Task 7 switches every path to the final one and removes the re-exports, +Task 8 runs one mutation pass. Every task leaves a green build and passes every guard. + +**Tech Stack:** Rust (edition 2024, `rust-version` 1.88, CI toolchain 1.99.1), cargo workspace +(resolver 3), clap 4, nix 0.31, serde; GitHub Actions (`ci.yml`, `release.yml`); cargo-deb +3.8.0, cargo-generate-rpm 0.21.1, makepkg in an Arch container; cargo-mutants 27.1.0; bash, +bsdtar, jq, python3 (only for the two one-off split scripts). + +**Spec:** `docs/superpowers/specs/2026-11-01-obsbot4linux-1.0-design.md` (revision 2), sections +3 (architecture), 3.2 (carry-over), 3.3 (licence), 10.1 (branches and rules), 10.2 (stage G), +11 (testing), 12.1 (packages). + +## Global Constraints + +- "No behaviour change." (spec 10.2). Not one byte sent to the camera, no ioctl, no timing and + no CLI output may differ. The only edits a move may make are listed per task: `use` lines, + `mod` declarations, module paths in code and comments, `pub(crate)` on items another file now + uses, file headers and module doc comments of new files. +- Crates: `crates/obsbot-device` (package `obsbot-device`, library `obsbot_device`) and + `crates/obsbot4linux` (package `obsbot4linux`, binary `obsbot4linux`). `obsbot-proto` and + `obsbot4linuxd` are created in stage W0, not here. +- "All crates take `version`, `rust-version` and `license = "GPL-3.0-or-later"` from + `[workspace.package]`." (spec 3) +- "Files that carry an EUPL-1.2 Article 5 note keep it, and the note follows their code: every + 1.0 file that receives code from such a file carries the note too" (spec 3.3). +- Packages as today: "Arch, deb, rpm and tarball, each for x86_64 and aarch64: eight + artifacts." "There is no libudev dependency." "Packages do not enable the socket unit." + (spec 12.1). The package name stays `obsbot4linux` in every format. +- The 0.13 daemon and CLI keep working on top of the workspace (spec 10.2). +- `main` is not touched by this plan. Every task is one pull request into `rebuild` with the + `required` check green and a review by someone other than its author (spec 10.1). When `main` + moves, `rebuild` is rebased onto it between pull requests (spec 10.1). +- No camera I/O. Nothing in this plan runs the binary except with `--help` or `--version`. The + camera smoke test (CS-0) comes after the tasks and gates the release, not a task. +- Commits are signed; messages are written through a quoted heredoc and end with the + `Co-Authored-By:` line of the model that wrote them. No session trailer, no session link. +- Public text (code comments, commit messages, pull requests): English, plain, factual; no + private paths or names, no serial numbers, no vendor-app or firmware internals (spec 8.5). +- Builds run with low priority and their own target directory: + `export CARGO_TARGET_DIR="$SCRATCH/target-"`, `nice -n 19`, `-j 3`. `SCRATCH` is a + directory outside the repository (for example `SCRATCH=$(mktemp -d)`). Every wait has a + timeout. + +## Review Focus + +1. **Timing constants and the one sequence counter across the `camera.rs` split.** A reply gap + of 50 ms, 20 reply attempts, a pair gap of 100 ms, a lock wait of 5 s and exactly one + process-wide `static SEQ` must survive the split; no unit test sees a changed gap or a second + counter. Pinned by the move check (G6) and by Task 6, Step 7 (grep counts). +2. **ioctl definitions and `#[repr(C)]` layouts moved between `transport/uvc.rs` and + `enumeration.rs`.** A copy instead of a move, or a changed number, compiles and passes the + tests. Pinned by G6 and Task 4, Step 6 (each of the five ioctls and four layouts exists + exactly once, with its 0.13 numbers). +3. **The release profile.** `[profile.release] strip = true` must move to the workspace root; + in a member manifest cargo ignores it and the packages ship an unstripped binary with the + same file list. Pinned by Task 2, Step 12 (`file` says `stripped`). +4. **Dependency versions and features.** Two crates unify features differently than one; the + lock file must resolve the same crates at the same versions. Pinned by Task 2, Step 11 + (`cargo metadata` before and after). +5. **Package names and metadata.** The deb and rpm name, description, licence and asset paths + come from the binary crate's manifest after the move. Pinned by Task 2, Step 11 (package + metadata before and after) and by the `contents` job of the release dry run (G5). + +--- + +## Before Task 1 + +These are not tasks of this plan; the coordinator checks them before the first pull request. + +- **P1. Ruleset for `rebuild`** (spec 10.1): signed commits, the `required` check, no deletion. + It does not exist yet (checked 2026-11-13). Without non-fast-forward protection, because + `rebuild` is rebased when `main` moves: + + ```bash + gh api -X POST repos/hellwand/obsbot4linux/rulesets --input - <<'EOF' + {"name": "rebuild", "target": "branch", "enforcement": "active", + "conditions": {"ref_name": {"include": ["refs/heads/rebuild"], "exclude": []}}, + "bypass_actors": [{"actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always"}], + "rules": [{"type": "deletion"}, {"type": "required_signatures"}, + {"type": "required_status_checks", "parameters": {"do_not_enforce_on_create": false, + "strict_required_status_checks_policy": true, + "required_status_checks": [{"context": "required", "integration_id": 15368}]}}]} + EOF + ``` + + Expected: a JSON answer with `"name": "rebuild"`; afterwards + `gh api repos/hellwand/obsbot4linux/rulesets --jq '.[].name'` lists `main` and `rebuild`. + +- **P2. CI on `rebuild`.** `ci.yml` and `release.yml` already run for pull requests into + `rebuild` (checked on `origin/main` 66287b5). Nothing to do. +- **P3. Camera-free fixes in carried code land on `main` first** (spec 10.1, 10.2 "Depends + on"). Tasks 1 to 7 can start before they land; each later rebase of `rebuild` onto `main` then + carries them (git follows the renames; a `main` change to `camera.rs` after Task 6 has to be + ported by hand into the split file, noted in the tracking issue). Task 8's named mutants need + three of them (Task 8, Step 5): a test seam for the answer poll (rejected, empty, no answer, + read error), a test for "a rejected tracking query gives `tracking: null`, not a failed read", + and the tilt negation of `gimbal` as a tested pure function. They are not filed yet. + +## Decisions + +Engineering decisions for this stage, with the short reason. + +1. **Package names.** The workspace has two packages: `obsbot-device` (library `obsbot_device`, + `publish = false`, no packaging metadata) and `obsbot4linux` (the binary, with + `[package.metadata.deb]` and `[package.metadata.generate-rpm]`). cargo-deb and + cargo-generate-rpm name the package after the cargo package, so it stays `obsbot4linux`; the + Arch name comes from `pkgname=obsbot4linux` in the PKGBUILD and the tarball name from + `release.yml`. The release jobs select the package with `cargo deb -p obsbot4linux` and + `cargo generate-rpm -p crates/obsbot4linux` (cargo-generate-rpm 0.21.1 takes the crate's + directory; checked). Asset paths in the binary crate's metadata are relative to its + directory (`../../README.md`); `target/release/obsbot4linux` keeps meaning the workspace + target directory (checked with both tools: same file list as 0.13.2, same deb control file). +2. **Version.** `[workspace.package] version = "0.13.2"`; every crate has + `version.workspace = true`. The version stays 0.13.2 through the whole stage: nothing is + released from `rebuild`. `release.yml` and `scripts/changelog-section.sh` read the first + `^version = ` line of the root `Cargo.toml`, which is the `[workspace.package]` line, so they + keep working unchanged; the `msrv` jobs read `^rust-version = ` the same way. The bump to + 0.14.0 (workspace version, `Cargo.lock`, PKGBUILD `pkgver`, CHANGELOG heading) is the + release step after the camera smoke test, not a task here. +3. **The 0.13 binary keeps its name and behaviour.** It is `[[bin]] name = "obsbot4linux"` in + the package `obsbot4linux`; its modules `cli`, `client`, `daemon`, `proto` and `main` move + unchanged except their `use` lines. The clap name is the literal `name = "obsbot4linux"`, the + version comes from `CARGO_PKG_VERSION` of the same package (now inherited). The 0.13 library + target `obsbot4linux` disappears: its modules are `obsbot_device` now. Nothing outside the + repository links that library (checked). +4. **0.13 code that 1.0 does not carry goes to `obsbot_device::legacy`.** `state.rs` (spec 3.2: + dropped), the 0.13 status shape (`FullStatus`, `CAPABILITIES`, `LIMITS`, `StateView`, + `ZoneInfo`, `Zoom`, `zone_info`, `full_status`, `full_status_with`) and `current_pose` + (spec 3.2: not carried). The carried flows still call `State` (remembered gimbal pose and + exposure mode), so `legacy` has to live in the device crate during this stage. Stage W0 or + track A deletes `legacy` together with the 0.13 daemon and CLI; nothing in `legacy` is a + final path. +5. **File names.** `models.rs` becomes `registry.rs` and the detection half of `device.rs` + becomes `enumeration.rs`, the names of spec 3.1. The device half of `device.rs` becomes + `transport/uvc.rs` ("one implementation talks to USB UVC devices", spec 3.1). The exchange + part of `camera.rs` (short and framed writes, answer polling, the sequence counter) becomes + `family/exchange.rs`: it depends only on `transport` and `family::frame`, which is the + direction of 1.0. `frame.rs` and `streaming.rs` keep their file names under `family/`. +6. **Old module paths during the parallel wave.** Tasks 3 to 5 keep every old path valid with a + re-export in `lib.rs` (`pub use family::{frame, streaming};`, an inline `pub mod device`, + `pub use models::tiny2lite::{commands, presets, status};`), so none of them edits a file of + another task. Task 2 lays `lib.rs` out in blank-line separated groups, one per task, so the + three pull requests rebase onto each other without a conflict (checked). Task 7 removes every + re-export. +7. **Tests move with their code.** Every `#[cfg(test)] mod` moves as a whole with the module + it tests, including the tests milestone 0.13.3 added (`commands.rs`: every short-command + builder and all AI modes; `status.rs`: every field of the status block; `camera.rs`: the + tilt/pitch conversions, hand and zone readbacks). The `camera.rs` test modules go with the + functions they test: `framing_tests`, `live_pose_tests`, `angle_conversion_tests` and + `picture_and_sound_tests` to `models/tiny2lite/camera.rs`, `full_status_tests` to + `legacy/full_status.rs`. The test in `device.rs` that tests `first_by_id` goes to + `enumeration.rs`. The last two path segments of every test name (module and function) do + not change, which is what the test-name guard compares. +8. **Mutation pass** (spec 10.2 and 11): its own last task, Task 8. Tool + cargo-mutants 27.1.0, in place, over `obsbot-device` without `legacy/state.rs` and + `legacy/current_pose.rs` (not carried), with the whole workspace's tests. Survivors are + killed by new tests in that pull request (test modules only) or documented in its + description; a survivor in a function without I/O must be killed or shown equivalent. Not a + CI gate. +9. **CHANGELOG.** One line, in Task 2. The other tasks change nothing a user or packager sees. +10. **What the guards cannot see.** Panic messages carry source paths (`src/frame.rs:…` becomes + `crates/obsbot-device/src/family/frame.rs:…`); they are not designed output and are + accepted. Everything else a user sees is covered by G2, G3 and G5. + +## Target layout + +After Task 7. `crates/obsbot-device/src/`: + +| File | From (0.13.2) | Content | +| ------------------------------------------------------------ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lib.rs` | `src/lib.rs` | `pub mod enumeration, error, family, legacy, models, registry, transport` | +| `error.rs` | `src/error.rs` | unchanged | +| `registry.rs` | `src/models.rs` | `Model`, `MODELS`, `from_by_id`, `from_card`, tests | +| `enumeration.rs` | `src/device.rs` | `V4L2_CAP_META_CAPTURE`, `V4l2Capability`, `vidioc_querycap`, `Detected`, `card_name`, `detect`, `first_by_id`, test `by_id_detection_does_not_depend_on_directory_order` | +| `transport/mod.rs` | new | `pub mod uvc;` | +| `transport/uvc.rs` | `src/device.rs` | `LOCK_WAIT`, UVC query constants, `XU_SIZE`, `V4L2_CTRL_FLAG_DISABLED`, `UvcXuControlQuery`, `V4l2Control`, `V4l2QueryCtrl`, four ioctls, `CtrlRange`, `Device`, `lock_exclusive`, `check_len`, `impl Device`, two tests | +| `family/mod.rs` | new | `pub mod exchange, frame, streaming;` | +| `family/frame.rs` | `src/frame.rs` | unchanged | +| `family/streaming.rs` | `src/streaming.rs` | unchanged | +| `family/exchange.rs` | `src/camera.rs` | `REPLY_GAP`, `REPLY_ATTEMPTS`, `PAIR_GAP`, `sequence()`, `struct Camera`, `open`, `short`, `framed`, `pair`, `query`, `query_to`, `answer` | +| `models/mod.rs` | new | `pub mod tiny2lite;` | +| `models/tiny2lite/mod.rs` | new | `pub mod camera, commands, presets, status;` | +| `models/tiny2lite/commands.rs` | `src/commands.rs` | unchanged but `use` lines | +| `models/tiny2lite/presets.rs` | `src/presets.rs` | unchanged but `use` lines | +| `models/tiny2lite/status.rs` | `src/status.rs` | unchanged but `use` lines | +| `models/tiny2lite/camera.rs` | `src/camera.rs` | `IMAGE_CONTROLS`, `FOCUS_ABSOLUTE`, `EXPOSURE_AUTO`, `FOCUS_AUTO`, `ZOOM_ABSOLUTE`, `ImageControl`, `LivePose`, `Zone`, `Hand`, `HandArea`, `hand_enabled`, `tilt_to_pitch_range`, `pitch_to_tilt_range`, `Framing`, `Offset`, `Identity`, `decode_volume`, `manual_exposure`, `auto_zoom_speed_reading`; `impl Camera`: `status` and every read and write flow from `tracking` to `framing` and from `sleep` to `presets`, `save_preset`, `rename_preset`, `delete_preset`, `recall_preset`; test modules `framing_tests`, `live_pose_tests`, `angle_conversion_tests`, `picture_and_sound_tests` | +| `legacy/mod.rs` | new | `pub mod current_pose, full_status, state;` | +| `legacy/state.rs` | `src/state.rs` | unchanged but `use` lines | +| `legacy/full_status.rs` | `src/camera.rs` | `CAPABILITIES`, `Zoom`, `FullStatus`, `Limits`, `LIMITS`, `StateView`, `ZoneInfo`; `impl Camera`: `zone_info`, `full_status`, `full_status_with`; test module `full_status_tests` | +| `legacy/current_pose.rs` | `src/camera.rs` | `PAN_ABSOLUTE`, `TILT_ABSOLUTE`; `impl Camera`: `current_pose` | +| `../tests/byte_guard.rs`, `../tests/fixtures/byte-guard.txt` | Task 1 | byte guard | + +`crates/obsbot4linux/`: `src/{main,cli,client,daemon,proto}.rs` from `src/`, unchanged but +`use` lines and two module paths in code; `tests/cli_surface.rs` and +`tests/fixtures/cli-surface.txt` from Task 1. Root: `Cargo.toml` (virtual manifest), +`Cargo.lock`, everything else where it is. + +Visibility widened to `pub(crate)` (the only non-path edits of the stage): the fields `dev` and +`seq` of `Camera`, the methods `short`, `framed`, `pair`, `query`, `query_to`, `answer`, the +constants `PAIR_GAP` and `ZOOM_ABSOLUTE`, the method `current_pose`. + +## Guards + +Defined in Task 1, named in every task. "Base" is the commit the pull request starts from, +"head" its last commit. + +- **G1 Tests.** `nice -n 19 cargo test --workspace --locked -j 3` passes, and + `scripts/groundwork/test-names.sh` gives the same list for base and head: + `LC_ALL=C comm -3 "$SCRATCH/names-base.txt" "$SCRATCH/names-head.txt"` prints nothing + (Tasks 1 and 8 add names and say which). +- **G2 Bytes.** `cargo test --workspace --locked --test byte_guard` passes and the fixture + `byte-guard.txt` is not edited (a rename is fine): `git diff -M --stat HEAD` shows it + with `0` changed lines or not at all. +- **G3 CLI surface.** `cargo test --workspace --locked --test cli_surface` passes and + `cli-surface.txt` is not edited. +- **G4 Lint and MSRV.** `cargo fmt --check`; + `cargo clippy --workspace --all-targets --locked -- -D warnings`; the CI job `msrv` + (Rust 1.88) green. Locally the MSRV build needs rustup and is optional; CI is the check. +- **G5 Packaging.** The release dry run of `release.yml` on the pull request is green, + including the job `contents` (the file list of all eight packages equals + `packaging/contents.txt`). It runs when the pull request touches + `.github/workflows/release.yml`, `packaging/**`, `scripts/**`, a `Cargo.toml`, `Cargo.lock`, `README.md`, + `CHANGELOG.md`, `LICENSE` or `renovate.json`: Tasks 1 and 2. The tag run of 0.14.0 runs it + again on the final tree. +- **G6 Move check.** `scripts/groundwork/move-check.sh HEAD` prints only the lines the + task lists under "Expected move check". +- **G7 Review.** `git diff -M --stat HEAD` shows the moves as renames; the reviewer reads + every changed line outside a pure rename. + +The gate a builder runs before every push: + +```bash +export CARGO_TARGET_DIR="$SCRATCH/target-" +nice -n 19 cargo fmt --check +nice -n 19 cargo clippy --workspace --all-targets --locked -j 3 -- -D warnings +nice -n 19 cargo test --workspace --locked -j 3 +shellcheck scripts/*.sh scripts/groundwork/*.sh +actionlint .github/workflows/*.yml +``` + +## Order and parallel work + +| Task | Key | Wave | Starts from | Depends on | +| --------------------------- | ------------------------------ | ---- | --------------------------------- | ---------- | +| 1 Guards | `groundwork-guards` | 1 | `origin/rebuild` | – | +| 2 Workspace | `groundwork-workspace` | 2 | branch of Task 1 | 1 | +| 3 family | `groundwork-family` | 3 | branch of Task 2 | 2 | +| 4 transport and enumeration | `groundwork-transport` | 3 | branch of Task 2 | 2 | +| 5 tiny2lite modules | `groundwork-tiny2lite-modules` | 3 | branch of Task 2 | 2 | +| 6 Split `camera.rs` | `groundwork-camera-split` | 4 | `origin/rebuild` after 1–5 landed | 3, 4, 5 | +| 7 Final paths | `groundwork-final-paths` | 5 | branch of Task 6 | 6 | +| 8 Mutation pass | `groundwork-mutants` | 6 | branch of Task 7 | 7 | + +Tasks 3, 4 and 5 run side by side: they own disjoint files and one blank-line separated group +of `lib.rs` each. Everything else is stacked, because each task moves files the next one +needs. The camera split cannot run beside the wave-3 tasks: it needs the directories they +create. Landing is a fast-forward of `rebuild` in key order (1, 2, 3, 4, 5, then 6, 7, 8); +Tasks 3 to 5 rebase onto each other without conflict in any order. Branch names: +`groundwork/`. + +--- + +### Task 1: Guards in the 0.13 layout + +**Files:** + +- Create: `tests/byte_guard.rs`, `tests/fixtures/byte-guard.txt` (generated), + `tests/cli_surface.rs`, `tests/fixtures/cli-surface.txt` (generated), + `scripts/groundwork/move-check.sh`, `scripts/groundwork/test-names.sh`, + `scripts/package-contents.sh`, `packaging/contents.txt` (generated from the dry run) +- Modify: `.github/workflows/release.yml` (new job `contents`; `release` needs it) + +**Interfaces:** + +- Consumes: the 0.13 library paths `obsbot4linux::commands`, `obsbot4linux::frame`, + `obsbot4linux::presets`; the binary `obsbot4linux`. +- Produces: integration tests `byte_guard::builders_emit_the_recorded_bytes`, + `cli_surface::help_texts_are_unchanged`, `cli_surface::version_is_the_package_version`; + environment switches `BYTE_GUARD_BLESS` and `CLI_SURFACE_BLESS` (only this task sets them); + `scripts/groundwork/test-names.sh REV`, `scripts/groundwork/move-check.sh OLD_REV [NEW_REV]`, + `scripts/package-contents.sh DIR X.Y.Z`; the `release.yml` job `contents` comparing with + `packaging/contents.txt`. + +- [ ] **Step 1: Branch** + +```bash +git fetch origin +git switch -c groundwork/guards origin/rebuild +``` + +- [ ] **Step 2: Write the byte guard** (`tests/byte_guard.rs`; `cargo fmt` formats it in Step 4) + +```rust +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Byte guard of the groundwork stage (rebuild plan, stage G): every frame and short command the +//! builders emit for a fixed list of calls, one line per call, compared with +//! `tests/fixtures/byte-guard.txt`. That file was written from the 0.13 code before the +//! workspace move, so a move that changes one byte that would go to the camera fails here. +//! +//! Only the task that adds this test writes the file: +//! `BYTE_GUARD_BLESS=1 cargo test --test byte_guard`. Every later run compares. + +use clap::ValueEnum; +use obsbot4linux::commands::{self as cmd, *}; +use obsbot4linux::frame::{self, FRAME_SIZE}; +use obsbot4linux::presets::{self, Pose, StartupAi}; +use std::fmt::Display; + +const FIXTURE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/byte-guard.txt"); +const SEQ: u16 = 0x1234; +const SEQS: [u16; 4] = [SEQ, SEQ + 1, SEQ + 2, SEQ + 3]; + +/// How one builder result is written into the fixture. +trait Show { + fn show(&self) -> String; +} + +impl Show for [u8; N] { + fn show(&self) -> String { + hex::encode(self) + } +} + +impl Show for [[u8; N]; M] { + fn show(&self) -> String { + self.iter().map(hex::encode).collect::>().join(" ") + } +} + +impl Show for u8 { + fn show(&self) -> String { + self.to_string() + } +} + +impl Show for u16 { + fn show(&self) -> String { + format!("{self:#06x}") + } +} + +impl Show for bool { + fn show(&self) -> String { + self.to_string() + } +} + +impl Show for Option { + fn show(&self) -> String { + self.as_ref().map_or("none".into(), Show::show) + } +} + +impl Show for Result { + fn show(&self) -> String { + match self { + Ok(v) => v.show(), + Err(e) => format!("error: {e}"), + } + } +} + +fn rec(out: &mut Vec, label: String, value: impl Show) { + out.push(format!("{label} = {}", value.show())); +} + +/// Poses at and beyond every bound the pose builders check, and an asymmetric one. +const POSES: [(f32, f32, f32); 10] = [ + (10.5, -5.25, 2.0), + (0.0, 0.0, 1.0), + (-150.0, -70.0, 1.0), + (150.0, 90.0, 4.0), + (150.1, 0.0, 1.0), + (0.0, 90.5, 1.0), + (0.0, -70.5, 1.0), + (0.0, 0.0, 4.01), + (0.0, 0.0, 0.99), + (f32::NAN, 0.0, 1.0), +]; + +fn poses() -> impl Iterator { + POSES.into_iter().map(|(pan, tilt, zoom)| Pose { pan, tilt, zoom }) +} + +/// Area ranges (min, max) in the camera's angles, at and beyond the bounds, and wrong ways round. +const RANGES: [(f32, f32); 11] = [ + (-45.0, 45.0), + (-150.0, 150.0), + (-150.5, 0.0), + (0.0, 150.5), + (-90.0, 70.0), + (-90.5, 0.0), + (0.0, 70.5), + (-30.0, 10.0), + (10.0, 10.0), + (10.0, -10.0), + (f32::NAN, 1.0), +]; + +type Short3 = fn(bool) -> [u8; 3]; +type Framed = fn(bool, u16) -> [u8; FRAME_SIZE]; +type SeqOnly = fn(u16) -> [u8; FRAME_SIZE]; + +fn record() -> Vec { + let mut out = Vec::new(); + let o = &mut out; + + // frame codec + for seq in [0x0000, 0x0001, SEQ, 0xffff] { + let q = frame::query_frame(0x04, 0x0104, seq); + rec(o, format!("frame::query_frame(0x04, 0x0104, {seq:#06x})"), q); + let f = frame::frame(0x02, 0xa0c2, &[1, 0, 0, 0], seq); + rec(o, format!("frame::frame(0x02, 0xa0c2, [1 0 0 0], {seq:#06x})"), f); + let w = frame::query_frame_with(0x04, 0x1504, &[7, 0, 0, 0], seq); + rec(o, format!("frame::query_frame_with(0x04, 0x1504, [7 0 0 0], {seq:#06x})"), w); + } + let full = frame::frame(0x04, 0x3e44, &[0xa5; frame::MAX_DATA], SEQ); + rec(o, "frame::frame(0x04, 0x3e44, [0xa5; MAX_DATA])".into(), full); + rec(o, "frame::crc16_usb(\"123456789\")".into(), frame::crc16_usb(b"123456789")); + + // short commands (selector 6) + let short3: [(&str, Short3); 6] = [ + ("hdr", cmd::hdr), + ("led_blink", cmd::led_blink), + ("mirror", cmd::mirror), + ("mic_in_sleep", cmd::mic_in_sleep), + ("auto_gain", cmd::auto_gain), + ("microphone", cmd::microphone), + ]; + for (name, f) in short3 { + for on in [false, true] { + rec(o, format!("cmd::{name}({on})"), f(on)); + } + } + for on in [false, true] { + rec(o, format!("cmd::sleep_background_mirror({on})"), cmd::sleep_background_mirror(on)); + } + for m in AiMode::value_variants() { + rec(o, format!("cmd::ai_mode({m:?})"), cmd::ai_mode(*m)); + rec(o, format!("cmd::default_work_mode({m:?})"), cmd::default_work_mode(*m)); + } + for s in [i16::MIN, -600, -1, 0, 1, 600, i16::MAX] { + rec(o, format!("cmd::auto_sleep({s})"), cmd::auto_sleep(s)); + } + for s in [0u16, 1, 600, 32767, 32768, u16::MAX] { + rec(o, format!("cmd::auto_sleep_off({s})"), cmd::auto_sleep_off(s)); + } + for b in SleepBackground::value_variants() { + rec(o, format!("cmd::sleep_background({b:?})"), cmd::sleep_background(*b)); + } + for b in 0..=4u8 { + rec(o, format!("cmd::led({b})"), cmd::led(b)); + } + for p in [0u8, 1, 50, 100, 101, 255] { + rec(o, format!("cmd::wake_zoom({p})"), cmd::wake_zoom(p)); + } + for f in [0.99f32, 1.0, 1.5, 2.5, 4.0, 4.01, f32::NAN] { + rec(o, format!("cmd::wake_zoom_percent({f})"), cmd::wake_zoom_percent(f)); + } + for v in FieldOfView::value_variants() { + rec(o, format!("cmd::field_of_view({v:?})"), cmd::field_of_view(*v)); + } + for v in NoiseReduction::value_variants() { + rec(o, format!("cmd::noise_reduction({v:?})"), cmd::noise_reduction(*v)); + } + for v in PickupDistance::value_variants() { + rec(o, format!("cmd::pickup_distance({v:?})"), cmd::pickup_distance(*v)); + } + for v in ExposureMode::value_variants() { + rec(o, format!("cmd::exposure_mode_select({v:?})"), cmd::exposure_mode_select(*v)); + } + rec(o, "cmd::startup_go()".into(), cmd::startup_go()); + + // framed commands (selector 2) + let framed: [(&str, Framed); 9] = [ + ("invert", cmd::invert), + ("zone_tracking", cmd::zone_tracking), + ("hand_pan", cmd::hand_pan), + ("hand_tilt", cmd::hand_tilt), + ("hand_gimbal", cmd::hand_gimbal), + ("autofocus_mode", cmd::autofocus_mode), + ("zone_follow", cmd::zone_follow), + ("sleep", cmd::sleep), + ("auto_zoom", cmd::auto_zoom), + ]; + for (name, f) in framed { + for on in [false, true] { + rec(o, format!("cmd::{name}({on})"), f(on, SEQ)); + } + } + let seq_only: [(&str, SeqOnly); 6] = [ + ("zone_return_go", cmd::zone_return_go), + ("hand_return_go", cmd::hand_return_go), + ("hand_return_reset", cmd::hand_return_reset), + ("factory_reset", cmd::factory_reset), + ("gimbal_stop", cmd::gimbal_stop), + ("zoom_stop", cmd::zoom_stop), + ]; + for (name, f) in seq_only { + rec(o, format!("cmd::{name}()"), f(SEQ)); + } + for v in TrackingSpeed::value_variants() { + rec(o, format!("cmd::tracking_speed({v:?})"), cmd::tracking_speed(*v, SEQ)); + } + for v in SpeedMode::value_variants() { + rec(o, format!("cmd::speed_mode({v:?})"), cmd::speed_mode(*v, SEQ)); + } + for v in FramingSize::value_variants() { + rec(o, format!("cmd::framing_size({v:?})"), cmd::framing_size(*v, SEQ)); + } + for v in AutoZoomSpeed::value_variants() { + rec(o, format!("cmd::auto_zoom_speed({v:?})"), cmd::auto_zoom_speed(*v, SEQ)); + } + for v in PartMode::value_variants() { + rec(o, format!("cmd::part_mode({v:?})"), cmd::part_mode(*v, SEQ)); + } + for v in HandSide::value_variants() { + rec(o, format!("cmd::hand_side({v:?})"), cmd::hand_side(*v, SEQ)); + } + for v in AutofocusMode::value_variants() { + rec(o, format!("cmd::autofocus_mode_select({v:?})"), cmd::autofocus_mode_select(*v, SEQ)); + } + for g in Gesture::value_variants() { + for on in [false, true] { + rec(o, format!("cmd::gesture({g:?}, {on})"), cmd::gesture(*g, on, SEQ)); + } + } + for r in [0.99f32, 1.0, 2.5, 4.0, 4.01, f32::NAN] { + rec(o, format!("cmd::gesture_zoom_ratio({r})"), cmd::gesture_zoom_ratio(r, SEQ)); + } + for s in [-1.0f32, -0.0, 0.0, 2.5, 600.0, f32::INFINITY, f32::NAN] { + rec(o, format!("cmd::hand_return_after({s})"), cmd::hand_return_after(s, SEQ)); + } + for v in [0u8, 1, 50, 100, 101, 255] { + rec(o, format!("cmd::audio_volume({v})"), cmd::audio_volume(v, SEQ)); + } + for x in [-0.01f32, 0.0, 0.25, 1.0, 1.01] { + for y in [-0.01f32, 0.0, 0.75, 1.0, 1.01] { + rec(o, format!("cmd::focus_point({x}, {y})"), cmd::focus_point(x, y, SEQ)); + rec(o, format!("cmd::view_center({x}, {y})"), cmd::view_center(x, y, SEQ)); + } + } + for r in RANGES { + for (yaw, pitch) in [(r, (-30.0, 10.0)), ((-45.0, 45.0), r)] { + rec(o, format!("cmd::zone_area({yaw:?}, {pitch:?})"), cmd::zone_area(yaw, pitch, SEQS)); + rec(o, format!("cmd::hand_area({yaw:?}, {pitch:?})"), cmd::hand_area(yaw, pitch, SEQS)); + } + } + rec(o, "cmd::hand_area_reset()".into(), cmd::hand_area_reset(SEQS)); + let axes = [None, Some(-150.0f32), Some(-70.0), Some(0.0), Some(12.5), Some(90.0), Some(150.0), Some(150.5), Some(-90.5)]; + for yaw in axes { + for pitch in axes { + rec(o, format!("cmd::gimbal_pose({yaw:?}, {pitch:?})"), cmd::gimbal_pose(yaw, pitch, SEQ)); + } + } + for (yaw, pitch) in [(None, None), (Some(12.5f32), None), (None, Some(-5.0f32)), (Some(12.5), Some(-5.0)), (Some(150.5), Some(0.0))] { + for speed in [0.99f32, 1.0, 45.0, 180.0, 180.5, f32::NAN] { + let f = cmd::gimbal_pose_speed(yaw, pitch, speed, SEQ); + rec(o, format!("cmd::gimbal_pose_speed({yaw:?}, {pitch:?}, {speed})"), f); + } + } + for pan in [-178.5f32, -178.0, -0.0, 0.0, 12.5, 178.0, 178.5, f32::NAN] { + for tilt in [-178.5f32, -178.0, -0.0, 0.0, 5.0, 178.0, 178.5] { + let f = cmd::gimbal_rate(pan, tilt, false, false, SEQ); + rec(o, format!("cmd::gimbal_rate({pan}, {tilt}, false, false)"), f); + } + } + for (mirror, invert) in [(true, false), (false, true), (true, true)] { + let f = cmd::gimbal_rate(12.5, 5.0, mirror, invert, SEQ); + rec(o, format!("cmd::gimbal_rate(12.5, 5, {mirror}, {invert})"), f); + } + for speed in [0i32, 1, 10, 30, 50, i32::MAX] { + for ratio in [0.99f32, 1.0, 2.5, 4.0, 4.01] { + rec(o, format!("cmd::zoom_to({speed}, {ratio})"), cmd::zoom_to(speed, ratio, SEQ)); + } + } + for vertical in [false, true] { + for f in [-0.17f32, -0.16, -0.0, 0.0, 0.1, 0.16, 0.17, f32::NAN] { + let b = cmd::framing_offset(vertical, f, SEQ); + rec(o, format!("cmd::framing_offset({vertical}, {f})"), b); + } + } + for pan in [-150.5f32, -150.0, 0.0, 150.0, 150.5] { + for tilt in [-70.5f32, -70.0, 0.0, 90.0, 90.5] { + rec(o, format!("cmd::gimbal_angles_ok({pan}, {tilt})"), cmd::gimbal_angles_ok(pan, tilt)); + } + } + + // presets, zone views, start pose and return positions + let names = ["", "Desk", "Bereich1", "äöü", "12345678901234567890123456789012", "123456789012345678901234567890123"]; + for slot in [0u8, 1, 2, 3, 255] { + for p in poses() { + rec(o, format!("presets::create({slot}, {p:?})"), presets::create(slot, p, SEQ)); + rec(o, format!("presets::update({slot}, {p:?})"), presets::update(slot, p, SEQ)); + } + for name in names { + rec(o, format!("presets::rename({slot}, {name:?})"), presets::rename(slot, name, SEQ)); + } + rec(o, format!("presets::delete({slot})"), presets::delete(slot, SEQ, SEQ + 1)); + rec(o, format!("presets::recall({slot})"), presets::recall(slot, SEQ)); + } + for id in [0u32, 1, 7, 255, u32::MAX] { + for p in poses() { + rec(o, format!("presets::view_add({id}, {p:?})"), presets::view_add(id, p, SEQ)); + } + for name in names { + rec(o, format!("presets::view_rename({id}, {name:?})"), presets::view_rename(id, name, SEQ)); + } + rec(o, format!("presets::view_remove({id})"), presets::view_remove(id, SEQ)); + rec(o, format!("presets::view_go({id})"), presets::view_go(id, SEQ)); + for command in [0x1504u16, 0x1444] { + let q = presets::view_query(command, id, SEQ); + rec(o, format!("presets::view_query({command:#06x}, {id})"), q); + } + } + for source in [0u32, 1, 2, 3, 4] { + for p in poses() { + let f = presets::startup_pose(source, p, SEQ); + rec(o, format!("presets::startup_pose({source}, {p:?})"), f); + } + } + for p in poses() { + rec(o, format!("presets::zone_return_set({p:?})"), presets::zone_return_set(p, SEQ)); + rec(o, format!("presets::hand_return_set({p:?})"), presets::hand_return_set(p, SEQ)); + } + for ai in StartupAi::value_variants() { + let f = presets::startup_picture_settings(ai.byte(), SEQ); + rec(o, format!("presets::startup_picture_settings({ai:?})"), f); + } + let block: Vec = (0u8..48).collect(); + for len in [0usize, 39, 40, 48] { + for ai in [0x00u8, 0xfe, 0xff] { + let f = presets::startup_with_ai(&block[..len], ai, SEQ); + rec(o, format!("presets::startup_with_ai(0..{len}, {ai:#04x})"), f); + } + } + out +} + +#[test] +fn builders_emit_the_recorded_bytes() { + let now = record().join("\n") + "\n"; + if std::env::var_os("BYTE_GUARD_BLESS").is_some() { + std::fs::write(FIXTURE, &now).unwrap(); + return; + } + let recorded = std::fs::read_to_string(FIXTURE).expect("tests/fixtures/byte-guard.txt"); + let (now, recorded): (Vec<&str>, Vec<&str>) = (now.lines().collect(), recorded.lines().collect()); + assert_eq!(now.len(), recorded.len(), "the number of recorded calls changed"); + for (n, r) in now.iter().zip(&recorded) { + assert_eq!(n, r, "a builder emits other bytes than before the move"); + } +} +``` + +- [ ] **Step 3: Run it to see it fail** (there is no recording yet) + +Run: `nice -n 19 cargo test --locked -j 3 --test byte_guard` +Expected: FAIL, `panicked at tests/byte_guard.rs:…: tests/fixtures/byte-guard.txt: Os { code: 2, kind: NotFound, … }` + +- [ ] **Step 4: Record the bytes of the base commit, format, and check that it passes** + +```bash +mkdir -p tests/fixtures +BYTE_GUARD_BLESS=1 nice -n 19 cargo test --locked -j 3 --test byte_guard +nice -n 19 cargo fmt +nice -n 19 cargo test --locked -j 3 --test byte_guard +wc -l tests/fixtures/byte-guard.txt +head -n 1 tests/fixtures/byte-guard.txt +``` + +Expected: both runs `test result: ok. 1 passed`; `831 tests/fixtures/byte-guard.txt` on top +of 66287b5 (another number only if `main` changed a builder's call list, which it cannot); +first line `frame::query_frame(0x04, 0x0104, 0x0000) = aa0100000c0085940a0404010000…`. + +- [ ] **Step 5: Prove the guard fails on one changed byte, then undo** + +```bash +sed -i 's/\[0x01, 0x01, on as u8\]/[0x01, 0x01, !on as u8]/' src/commands.rs +nice -n 19 cargo test --locked -j 3 --test byte_guard 2>&1 | grep -E 'left:|right:|test result' +git checkout -- src/commands.rs +``` + +Expected: `left: "cmd::hdr(false) = 010101"`, `right: "cmd::hdr(false) = 010100"`, +`test result: FAILED`. After the checkout `git status --short src` prints nothing. + +- [ ] **Step 6: Write the CLI surface guard** (`tests/cli_surface.rs`) + +```rust +// SPDX-License-Identifier: GPL-3.0-or-later + +//! CLI surface guard of the groundwork stage (rebuild plan, stage G): the `--help` text of the +//! CLI and of every subcommand, compared with `tests/fixtures/cli-surface.txt`, which was +//! written from the 0.13 code before the workspace move, and `--version`. Only `--help` and +//! `--version` run: nothing opens a device or a socket. +//! +//! Only the task that adds this test writes the file: +//! `CLI_SURFACE_BLESS=1 cargo test --test cli_surface`. Every later run compares. + +use std::process::Command; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/cli-surface.txt" +); + +fn run(args: &[&str]) -> String { + let out = Command::new(env!("CARGO_BIN_EXE_obsbot4linux")) + .args(args) + .output() + .expect("obsbot4linux runs"); + assert!(out.status.success(), "obsbot4linux {args:?} failed"); + String::from_utf8(out.stdout).expect("UTF-8 output") +} + +/// The names in the "Commands:" section of a help text: the lines indented by exactly two +/// spaces (a longer indent continues a description). +fn subcommands(help: &str) -> Vec { + help.lines() + .skip_while(|l| *l != "Commands:") + .skip(1) + .take_while(|l| l.starts_with(' ')) + .filter(|l| !l.starts_with(" ")) + .filter_map(|l| l.split_whitespace().next()) + .filter(|name| *name != "help") + .map(String::from) + .collect() +} + +fn walk(path: &mut Vec, out: &mut String) { + let mut args: Vec<&str> = path.iter().map(String::as_str).collect(); + args.push("--help"); + let help = run(&args); + out.push_str(&format!("=== obsbot4linux {}\n{help}", args.join(" "))); + for sub in subcommands(&help) { + path.push(sub); + walk(path, out); + path.pop(); + } +} + +#[test] +fn help_texts_are_unchanged() { + let mut out = String::new(); + walk(&mut Vec::new(), &mut out); + // a hidden subcommand is not listed under "Commands:" + walk(&mut vec!["gimbal".into(), "center".into()], &mut out); + if std::env::var_os("CLI_SURFACE_BLESS").is_some() { + std::fs::write(FIXTURE, &out).unwrap(); + return; + } + let recorded = std::fs::read_to_string(FIXTURE).expect("tests/fixtures/cli-surface.txt"); + assert_eq!(out, recorded, "a help text of the CLI changed"); +} + +#[test] +fn version_is_the_package_version() { + // the workspace move must keep the version the binary reports + assert_eq!( + run(&["--version"]), + format!("obsbot4linux {}\n", env!("CARGO_PKG_VERSION")) + ); +} +``` + +- [ ] **Step 7: Run it to see it fail, record, run again** + +```bash +nice -n 19 cargo test --locked -j 3 --test cli_surface 2>&1 | grep 'test result' +CLI_SURFACE_BLESS=1 nice -n 19 cargo test --locked -j 3 --test cli_surface +nice -n 19 cargo test --locked -j 3 --test cli_surface 2>&1 | grep 'test result' +grep -c '^=== ' tests/fixtures/cli-surface.txt +``` + +Expected: first `test result: FAILED. 1 passed; 1 failed` (no recording), then +`test result: ok. 2 passed`; `88` help texts (root, 86 listed subcommands at every level, +the hidden `gimbal center`). + +- [ ] **Step 8: Prove it fails on a changed help text, then undo** + +```bash +sed -i 's/about = "Control the OBSBOT Tiny 2 Lite"/about = "Control the OBSBOT Tiny 2 Lite!"/' src/cli.rs +nice -n 19 cargo test --locked -j 3 --test cli_surface 2>&1 | grep -E 'help text|test result' +git checkout -- src/cli.rs +``` + +Expected: `a help text of the CLI changed` and `test result: FAILED. 1 passed; 1 failed`. + +- [ ] **Step 9: Write the three scripts and make them executable** + +`scripts/groundwork/test-names.sh`: + +```bash +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Test-name guard of the groundwork stage. Lists the tests of a commit by the last two parts of +# their path (module and name, which a move keeps), sorted. Run it for the base and the head of +# a pull request and compare the two lists: a pure move changes no line. +# Usage: scripts/groundwork/test-names.sh REV > names.txt +set -euo pipefail +rev=${1:?usage: test-names.sh REV} +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +git archive "$rev" | tar -x -C "$work" +cd "$work" +CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$work/target} nice -n 19 \ + cargo test --workspace --locked -q -j 3 -- --list --format terse 2>/dev/null | + awk -F': ' '$2 == "test" { n = split($1, p, "::"); print (n > 1 ? p[n - 1] "::" : "") p[n] }' | + LC_ALL=C sort +``` + +`scripts/groundwork/move-check.sh`: + +```bash +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Move check of the groundwork stage. Prints the lines of Rust code that differ between two +# commits, compared as a multiset: the file a line is in and the order of lines do not count, +# and `use` statements, `mod name;` declarations, `pub(crate)` / `pub(super)`, indentation and +# blank lines are set aside. A pure move prints only the structural lines its task lists. +# Usage: scripts/move-check.sh OLD_REV [NEW_REV] (NEW_REV defaults to HEAD) +set -euo pipefail +old=${1:?usage: move-check.sh OLD_REV [NEW_REV]} +new=${2:-HEAD} + +lines() { + git ls-tree -r --name-only "$1" | grep '\.rs$' | while read -r f; do + git show "$1:$f" + done | awk ' + in_use { if (/;[[:space:]]*$/) in_use = 0; next } + /^[[:space:]]*(pub(\([a-z]+\))? )?use / { if (!/;[[:space:]]*$/) in_use = 1; next } + /^[[:space:]]*(pub(\([a-z]+\))? )?mod [a-z0-9_]+;[[:space:]]*$/ { next } + /^[[:space:]]*$/ { next } + { sub(/^[[:space:]]+/, ""); sub(/^pub\((crate|super)\) /, ""); print } + ' | LC_ALL=C sort +} + +diff <(lines "$old") <(lines "$new") | grep '^[<>]' || true +``` + +`scripts/package-contents.sh`: + +```bash +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Lists the files of every package a release build made: one line per entry, " +# ", sorted, with the version replaced by VERSION and the architecture by ARCH, +# so that two builds can be compared. The release workflow compares the list with +# packaging/contents.txt. Needs bsdtar (libarchive). +# Usage: scripts/package-contents.sh DIR X.Y.Z (DIR holds the obsbot4linux* packages) +set -eu +dir=${1:?usage: package-contents.sh DIR X.Y.Z} +ver=${2:?usage: package-contents.sh DIR X.Y.Z} + +find "$dir" -type f -name 'obsbot4linux*' | while read -r pkg; do + name=$(basename "$pkg") + case $name in + *.deb) bsdtar -xOf "$pkg" 'data.tar*' | bsdtar -tvf - ;; + *) bsdtar -tvf "$pkg" ;; + esac | awk -v p="$name" '{ print p, $1, $NF }' +done | sed -e "s/$ver/VERSION/g" -e 's/x86_64\|aarch64\|amd64\|arm64/ARCH/g' | LC_ALL=C sort +``` + +```bash +chmod +x scripts/groundwork/test-names.sh scripts/groundwork/move-check.sh scripts/package-contents.sh +shellcheck scripts/*.sh scripts/groundwork/*.sh +``` + +Expected: shellcheck prints nothing. + +- [ ] **Step 10: Add the job `contents` to `.github/workflows/release.yml`** + +Insert the job `contents` before the job `release`, and add `contents` to the `needs` of +`release`. The end of the file's job list then reads (the job `release` keeps all its other +lines): + +```yaml + # Lists the files of every package and compares the list with packaging/contents.txt, so that + # a change to what a package holds shows up in review. A release needs it too. + contents: + needs: [version, binary, deb, rpm, arch] + runs-on: ubuntu-22.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + path: dist + pattern: dist-* + merge-multiple: true + - run: sudo apt-get install -y --no-install-recommends libarchive-tools + - name: Compare the package contents + env: + VER: ${{ needs.version.outputs.ver }} + run: | + scripts/package-contents.sh dist "$VER" > contents.txt + cat contents.txt + diff -u packaging/contents.txt contents.txt + # the list itself, also when it differs, to update packaging/contents.txt from + - if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: package-contents + path: contents.txt + if-no-files-found: error + + # Tags only: a pull request builds the packages above and publishes nothing. + release: + if: startsWith(github.ref, 'refs/tags/') + needs: [binary, deb, rpm, arch, contents] +``` + +Run: `actionlint .github/workflows/*.yml` +Expected: no output. + +- [ ] **Step 11: Gate** + +```bash +export CARGO_TARGET_DIR="$SCRATCH/target-groundwork-guards" +nice -n 19 cargo fmt --check +nice -n 19 cargo clippy --all-targets --locked -j 3 -- -D warnings +nice -n 19 cargo test --locked -j 3 2>&1 | grep 'test result' +git add tests scripts .github/workflows/release.yml +git status --short # every file of this task staged, nothing else +``` + +Expected test results on top of 66287b5: `118 passed` (library), `25 passed` (binary), +`1 passed` (byte_guard), `2 passed` (cli_surface), `0 passed` (doc tests). + +Commit (Step 12) before the name lists, because `test-names.sh` reads committed trees. + +- [ ] **Step 12: Commit** + +```bash +git commit -F - <<'EOF' +Add guards for the groundwork move + +A byte guard records every frame and short command the builders emit for a fixed list of +calls, a CLI guard records the help text of every subcommand, and the release dry run lists +the files of every package. The workspace move must leave all three unchanged. + +Co-Authored-By: +EOF +scripts/groundwork/test-names.sh origin/rebuild > "$SCRATCH/names-base.txt" +scripts/groundwork/test-names.sh HEAD > "$SCRATCH/names-head.txt" +LC_ALL=C comm -3 "$SCRATCH/names-base.txt" "$SCRATCH/names-head.txt" +``` + +Expected from `comm`: exactly three lines, each indented by one tab (only in head): +`builders_emit_the_recorded_bytes`, `help_texts_are_unchanged`, +`version_is_the_package_version`. + +- [ ] **Step 13: Push, open the pull request into `rebuild`, record the package list** + +```bash +git push -u origin groundwork/guards +gh pr create -R hellwand/obsbot4linux --base rebuild --head groundwork/guards \ + --title "Groundwork: guards for the workspace move" --body-file "$SCRATCH/pr-body.md" +``` + +The body says: part of the groundwork stage, Task 1 of +`docs/superpowers/plans/2026-11-13-obsbot4linux-groundwork.md`; no behaviour change; what each +guard records. The release dry run starts (the pull request touches `release.yml` and +`scripts/`). Its job `contents` fails the first time: `packaging/contents.txt` does not exist +yet. The job uploads the list it made as the artifact `package-contents`; take it from there: + +```bash +run=$(gh run list -R hellwand/obsbot4linux --workflow release.yml --branch groundwork/guards \ + --limit 1 --json databaseId --jq '.[0].databaseId') +timeout 3600 gh run watch "$run" -R hellwand/obsbot4linux --interval 30 > /dev/null || true +gh run download "$run" -R hellwand/obsbot4linux -n package-contents -D "$SCRATCH/contents" +cp "$SCRATCH/contents/contents.txt" packaging/contents.txt +cut -d' ' -f1 packaging/contents.txt | sort -u | wc -l +grep -c 'obsbot4linux_VERSION_ARCH.deb' packaging/contents.txt +grep 'VERSION-1.ARCH.rpm' packaging/contents.txt | sort -u +``` + +Expected: `4` (each normalised package name stands for its two architectures); 28 deb lines (14 per architecture); the rpm +lines (each twice in the file, once per architecture): + +```text +obsbot4linux-VERSION-1.ARCH.rpm -rw-r--r-- ./usr/lib/systemd/user/obsbot4linux.service +obsbot4linux-VERSION-1.ARCH.rpm -rw-r--r-- ./usr/lib/systemd/user/obsbot4linux.socket +obsbot4linux-VERSION-1.ARCH.rpm -rw-r--r-- ./usr/share/doc/obsbot4linux/CHANGELOG.md +obsbot4linux-VERSION-1.ARCH.rpm -rw-r--r-- ./usr/share/doc/obsbot4linux/README.md +obsbot4linux-VERSION-1.ARCH.rpm -rw-r--r-- ./usr/share/licenses/obsbot4linux/LICENSE +obsbot4linux-VERSION-1.ARCH.rpm -rwxr-xr-x ./usr/bin/obsbot4linux +``` + +Commit `packaging/contents.txt` ("Record the package contents of 0.13.2", same trailer rule), +push, and wait for the checks: `timeout 3600 gh pr checks -R hellwand/obsbot4linux +--watch --interval 30`. Expected: `required` and every release job green, `contents` included. + +**Expected move check:** none (nothing moves). **Guards:** G1 (three names added), G2 and G3 +(created here), G4, G5 (`contents` green), G7. + +--- + +### Task 2: The workspace, files moved flat + +**Files:** + +- Create: `crates/obsbot-device/Cargo.toml`, `crates/obsbot4linux/Cargo.toml` +- Move (git mv): `src/{camera,commands,device,error,frame,lib,presets,state,status,streaming}.rs` + → `crates/obsbot-device/src/`; `src/models.rs` → `crates/obsbot-device/src/registry.rs`; + `src/{cli,client,daemon,main,proto}.rs` → `crates/obsbot4linux/src/`; + `tests/byte_guard.rs`, `tests/fixtures/byte-guard.txt` → `crates/obsbot-device/tests/`; + `tests/cli_surface.rs`, `tests/fixtures/cli-surface.txt` → `crates/obsbot4linux/tests/` +- Modify: `Cargo.toml` (virtual manifest), `Cargo.lock`, `crates/obsbot-device/src/lib.rs`, + `crates/obsbot-device/src/device.rs`, `crates/obsbot4linux/src/{cli,client,daemon}.rs`, + `crates/obsbot-device/tests/byte_guard.rs`, `.github/workflows/ci.yml`, + `.github/workflows/release.yml`, `README.md`, `CONTRIBUTING.md`, `CHANGELOG.md` +- Unchanged and checked: `packaging/arch/PKGBUILD`, `scripts/build-arch.sh` (they build and + test at the workspace root; the binary stays at `target/release/obsbot4linux`) + +**Interfaces:** + +- Consumes: Task 1's guards and scripts. +- Produces: package `obsbot-device` with the flat modules `obsbot_device::{camera, commands, + device, error, frame, presets, registry, state, status, streaming}`; package `obsbot4linux` + (binary only); `lib.rs` in four blank-line separated groups that Tasks 3, 4, 5 and 6 each + edit alone. + +- [ ] **Step 1: Branch from Task 1 and record the before state** + +```bash +git switch -c groundwork/workspace origin/groundwork/guards +export CARGO_TARGET_DIR="$SCRATCH/target-groundwork-workspace" +scripts/groundwork/test-names.sh HEAD > "$SCRATCH/names-base.txt" +cargo metadata --format-version 1 --locked | jq -r '.packages[] | select(.source != null) | "\(.name) \(.version)"' | sort > "$SCRATCH/deps-base.txt" +cargo metadata --no-deps --format-version 1 --locked | jq -c '.packages[] | select(.name == "obsbot4linux") | {name, version, license, description, homepage, repository, authors, edition, rust_version}' > "$SCRATCH/meta-base.txt" +git rev-parse HEAD > "$SCRATCH/base-rev.txt" +``` + +- [ ] **Step 2: Move the files** + +```bash +mkdir -p crates/obsbot-device/src crates/obsbot-device/tests/fixtures \ + crates/obsbot4linux/src crates/obsbot4linux/tests/fixtures +for f in camera commands device error frame lib presets state status streaming; do + git mv "src/$f.rs" "crates/obsbot-device/src/$f.rs" +done +git mv src/models.rs crates/obsbot-device/src/registry.rs +for f in cli client daemon main proto; do + git mv "src/$f.rs" "crates/obsbot4linux/src/$f.rs" +done +git mv tests/byte_guard.rs crates/obsbot-device/tests/byte_guard.rs +git mv tests/fixtures/byte-guard.txt crates/obsbot-device/tests/fixtures/byte-guard.txt +git mv tests/cli_surface.rs crates/obsbot4linux/tests/cli_surface.rs +git mv tests/fixtures/cli-surface.txt crates/obsbot4linux/tests/fixtures/cli-surface.txt +rmdir src tests/fixtures tests +``` + +- [ ] **Step 3: Write the root `Cargo.toml`** (replaces the whole file) + +```toml +[workspace] +resolver = "3" +members = ["crates/obsbot-device", "crates/obsbot4linux"] + +[workspace.package] +version = "0.13.2" +edition = "2024" +rust-version = "1.88" +authors = ["Thomas Kroll"] +license = "GPL-3.0-or-later" +repository = "https://github.com/hellwand/obsbot4linux" +homepage = "https://github.com/hellwand/obsbot4linux" +publish = false + +[workspace.dependencies] +obsbot-device = { path = "crates/obsbot-device" } +clap = { version = "4", features = ["derive"] } +nix = { version = "0.31", features = ["fs", "ioctl"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +hex = "0.4" + +[profile.release] +strip = true +``` + +- [ ] **Step 4: Write `crates/obsbot-device/Cargo.toml`** + +```toml +[package] +name = "obsbot-device" +description = "Camera access for obsbot4linux: transport, frame codec and the camera models" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish.workspace = true + +[dependencies] +clap.workspace = true +nix.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +hex.workspace = true +``` + +- [ ] **Step 5: Write `crates/obsbot4linux/Cargo.toml`** (the package metadata of the root + manifest, with paths relative to the crate) + +```toml +[package] +name = "obsbot4linux" +description = "Control OBSBOT webcams on Linux from the console and the DMS widget" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish.workspace = true +readme = "../../README.md" + +[[bin]] +name = "obsbot4linux" +path = "src/main.rs" + +[dependencies] +obsbot-device.workspace = true +clap.workspace = true +nix.workspace = true +serde.workspace = true +serde_json.workspace = true + +[package.metadata.deb] +maintainer = "Thomas Kroll " +section = "video" +extended-description = "Most settings OBSBOT Center offers, from the console and from the DMS bar widget.\nWhat is missing: https://github.com/hellwand/obsbot4linux/blob/main/docs/center-parity.md\nThe obsbot4linux daemon (systemd user socket) owns the camera state." +depends = "$auto" +license-file = ["../../LICENSE", "0"] +assets = [ + ["target/release/obsbot4linux", "usr/bin/", "755"], + ["../../packaging/systemd/obsbot4linux.service", "usr/lib/systemd/user/", "644"], + ["../../packaging/systemd/obsbot4linux.socket", "usr/lib/systemd/user/", "644"], + ["../../README.md", "usr/share/doc/obsbot4linux/", "644"], + ["../../CHANGELOG.md", "usr/share/doc/obsbot4linux/", "644"], +] + +[package.metadata.generate-rpm] +assets = [ + { source = "target/release/obsbot4linux", dest = "/usr/bin/obsbot4linux", mode = "755" }, + { source = "../../packaging/systemd/obsbot4linux.service", dest = "/usr/lib/systemd/user/obsbot4linux.service", mode = "644" }, + { source = "../../packaging/systemd/obsbot4linux.socket", dest = "/usr/lib/systemd/user/obsbot4linux.socket", mode = "644" }, + { source = "../../README.md", dest = "/usr/share/doc/obsbot4linux/README.md", mode = "644", doc = true }, + { source = "../../CHANGELOG.md", dest = "/usr/share/doc/obsbot4linux/CHANGELOG.md", mode = "644", doc = true }, + { source = "../../LICENSE", dest = "/usr/share/licenses/obsbot4linux/LICENSE", mode = "644" }, +] +``` + +- [ ] **Step 6: Write `crates/obsbot-device/src/lib.rs`** (one group per later task) + +```rust +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Control library for the OBSBOT Tiny 2 Lite. + +pub mod camera; +pub mod error; +pub mod state; + +pub mod frame; +pub mod streaming; + +pub mod device; +pub mod registry; + +pub mod commands; +pub mod presets; +pub mod status; +``` + +- [ ] **Step 7: Fix the paths that name the old crate or `models`** + +```bash +sed -i -e 's/^use crate::models::{self, Model};$/use crate::registry::{self, Model};/' \ + -e 's/models::from_card(/registry::from_card(/' -e 's/models::from_by_id(/registry::from_by_id(/' \ + crates/obsbot-device/src/device.rs +sed -i 's/obsbot4linux::/obsbot_device::/g' crates/obsbot4linux/src/cli.rs \ + crates/obsbot4linux/src/client.rs crates/obsbot4linux/src/daemon.rs +sed -i 's/obsbot_device::models::MODELS/obsbot_device::registry::MODELS/' crates/obsbot4linux/src/cli.rs +sed -i 's/^use obsbot4linux::/use obsbot_device::/' crates/obsbot-device/tests/byte_guard.rs +grep -rn 'obsbot4linux::\|crate::models' crates +``` + +Expected from the grep: nothing. + +- [ ] **Step 8: Update the lock file and check that only our packages changed** + +```bash +nice -n 19 cargo check --workspace -j 3 +git diff Cargo.lock +``` + +Expected: the diff adds a `[[package]] name = "obsbot-device"` entry (dependencies `clap`, +`hex`, `nix`, `serde`, `serde_json`, `thiserror`) and changes the `obsbot4linux` entry's +dependencies to `clap`, `nix`, `obsbot-device`, `serde`, `serde_json`. No other entry changes. + +- [ ] **Step 9: CI, release workflow and docs** + +`.github/workflows/ci.yml`: in the jobs `clippy`, `test` and `msrv` add `--workspace`: + +```yaml +- run: cargo clippy --workspace --all-targets --locked -- -D warnings +``` + +```yaml +- run: cargo test --workspace --locked +``` + +```yaml +cargo "+$msrv" test --workspace --locked +``` + +`.github/workflows/release.yml`: the same three lines in the job `test`; in `on.pull_request. +paths` after `- Cargo.lock` add `- crates/*/Cargo.toml`; in the job `deb` the package command +becomes + +```yaml +cargo deb -p obsbot4linux --no-build --deb-revision "" -o "dist/obsbot4linux_${ver}_$DEB.deb" +``` + +and in the job `rpm` + +```yaml +cargo generate-rpm -p crates/obsbot4linux -o "dist/obsbot4linux-$ver-1.$ARCH.rpm" +``` + +`CONTRIBUTING.md`, item 3: `cargo clippy --all-targets --locked -- -D warnings` becomes +`cargo clippy --workspace --all-targets --locked -- -D warnings`, and both `cargo test` become +`cargo test --workspace`. `README.md`: `` in `src/models.rs` `` becomes +`` in `crates/obsbot-device/src/registry.rs` ``. `CHANGELOG.md`, last line of +`## [Unreleased]` / `### Changed`: + +```markdown +- The source is a cargo workspace now: `crates/obsbot-device` holds the camera access, + `crates/obsbot4linux` the CLI and the daemon. The program, its output and the packages do not + change. +``` + +Run: `actionlint .github/workflows/*.yml` +Expected: no output. + +- [ ] **Step 10: Gate** + +```bash +nice -n 19 cargo fmt --check +nice -n 19 cargo clippy --workspace --all-targets --locked -j 3 -- -D warnings +nice -n 19 cargo test --workspace --locked -j 3 2>&1 | grep 'test result' +``` + +Expected: no fmt or clippy output; `118 passed`, `1 passed` (byte_guard), `25 passed`, +`2 passed` (cli_surface), `0 passed` (doc tests). + +- [ ] **Step 11: Dependencies and package metadata did not change** + +```bash +cargo metadata --format-version 1 --locked | jq -r '.packages[] | select(.source != null) | "\(.name) \(.version)"' | sort | diff "$SCRATCH/deps-base.txt" - +cargo metadata --no-deps --format-version 1 --locked | jq -c '.packages[] | select(.name == "obsbot4linux") | {name, version, license, description, homepage, repository, authors, edition, rust_version}' | diff "$SCRATCH/meta-base.txt" - +``` + +Expected: both diffs print nothing. + +- [ ] **Step 12: The release binary is still stripped** + +```bash +nice -n 19 cargo build --release --locked -j 3 +file "$CARGO_TARGET_DIR/release/obsbot4linux" | grep -o 'stripped$' +``` + +Expected: `stripped`. The packages themselves are built by the release dry run of the pull +request (G5). + +- [ ] **Step 13: Commit, guards, pull request** + +```bash +git add -A +git status --short +git commit -F - <<'EOF' +Make the source a cargo workspace + +The library modules move to crates/obsbot-device, models.rs as registry.rs, and the 0.13 CLI +and daemon to crates/obsbot4linux, which keeps the package name, the binary and the package +metadata. Version, rust-version and licence come from [workspace.package]. No behaviour change. + +Co-Authored-By: +EOF +scripts/groundwork/test-names.sh HEAD > "$SCRATCH/names-head.txt" +LC_ALL=C comm -3 "$SCRATCH/names-base.txt" "$SCRATCH/names-head.txt" +scripts/groundwork/move-check.sh "$(cat "$SCRATCH/base-rev.txt")" HEAD +git diff -M --stat "$(cat "$SCRATCH/base-rev.txt")" HEAD +``` + +Expected: `git status` lists only the files of this task; `comm` prints nothing; the stat +shows every moved file as a rename (`{src => crates/obsbot-device/src}/frame.rs | 0`, +`models.rs` renamed to `registry.rs` with `0`, `device.rs` with `6`, the two fixtures with `0`). + +**Expected move check:** + +```text +< && let Some(model) = models::from_card(&card) +> && let Some(model) = registry::from_card(&card) +< let Some(found) = obsbot4linux::device::detect() else { +> let Some(found) = obsbot_device::device::detect() else { +< let log = obsbot4linux::state::path().with_file_name("daemon.log"); +> let log = obsbot_device::state::path().with_file_name("daemon.log"); +< let model = models::from_by_id(&device.file_name()?.to_string_lossy())?; +> let model = registry::from_by_id(&device.file_name()?.to_string_lossy())?; +``` + +(`lib.rs` changes only `mod` lines, which the check sets aside; its doc line stays.) Push, +pull request into `rebuild` titled "Groundwork: make the source a cargo workspace", stacked on +Task 1's pull request; wait for the checks as in Task 1, Step 13. +**Guards:** G1, G2, G3, G4, G5 (the dry run runs: `Cargo.toml` changed; `contents` green with +the unchanged `packaging/contents.txt`), G6, G7. + +--- + +### Task 3: `family` (frame codec and on-air probe) + +**Files:** + +- Move: `crates/obsbot-device/src/frame.rs` → `crates/obsbot-device/src/family/frame.rs`, + `crates/obsbot-device/src/streaming.rs` → `crates/obsbot-device/src/family/streaming.rs` +- Create: `crates/obsbot-device/src/family/mod.rs` +- Modify: `crates/obsbot-device/src/lib.rs` (the group `pub mod frame; pub mod streaming;` + only) + +**Interfaces:** + +- Consumes: Task 2's flat `obsbot_device::{frame, streaming}`. +- Produces: `obsbot_device::family::frame` and `obsbot_device::family::streaming`; the old paths + `obsbot_device::frame` and `obsbot_device::streaming` stay valid through + `pub use family::{frame, streaming};` until Task 7. + +- [ ] **Step 1: Branch from Task 2, record the base** + +```bash +git switch -c groundwork/family origin/groundwork/workspace +export CARGO_TARGET_DIR="$SCRATCH/target-groundwork-family" +git rev-parse HEAD > "$SCRATCH/base-rev.txt" +scripts/groundwork/test-names.sh HEAD > "$SCRATCH/names-base.txt" +``` + +- [ ] **Step 2: Move** + +```bash +mkdir -p crates/obsbot-device/src/family +git mv crates/obsbot-device/src/frame.rs crates/obsbot-device/src/family/frame.rs +git mv crates/obsbot-device/src/streaming.rs crates/obsbot-device/src/family/streaming.rs +``` + +- [ ] **Step 3: Write `crates/obsbot-device/src/family/mod.rs`** + +```rust +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Building blocks that are not tied to one camera model: the frame codec and the on-air probe. + +pub mod frame; +pub mod streaming; +``` + +- [ ] **Step 4: Replace the group in `lib.rs`** + +The two lines `pub mod frame;` and `pub mod streaming;` become: + +```rust +pub mod family; +pub use family::{frame, streaming}; +``` + +- [ ] **Step 5: Gate.** Run the gate block of the Guards section. Expected: no output from + fmt, clippy, shellcheck and actionlint; five `test result: ok` lines with 118, 1, 25, 2 + and 0 passed tests (on top of 66287b5). + +- [ ] **Step 6: Commit, guards, pull request** + +```bash +git add -A +git commit -F - <<'EOF' +Move the frame codec and the on-air probe to family + +frame.rs and streaming.rs move unchanged; lib.rs keeps their old paths as re-exports until +the final path change of the groundwork stage. + +Co-Authored-By: +EOF +scripts/groundwork/test-names.sh HEAD > "$SCRATCH/names-head.txt" +LC_ALL=C comm -3 "$SCRATCH/names-base.txt" "$SCRATCH/names-head.txt" +scripts/groundwork/move-check.sh "$(cat "$SCRATCH/base-rev.txt")" HEAD +``` + +Expected: `comm` prints nothing. **Expected move check:** + +```text +> // SPDX-License-Identifier: GPL-3.0-or-later +> //! Building blocks that are not tied to one camera model: the frame codec and the on-air probe. +``` + +Pull request into `rebuild` titled "Groundwork: move the frame codec and the on-air probe to +family", stacked on Task 2's. **Guards:** G1, G2, G3, G4, G6, G7 (G5 does not run: no +packaging input changes). + +--- + +### Task 4: `transport` and `enumeration` (split of `device.rs`) + +**Files:** + +- Move: `crates/obsbot-device/src/device.rs` → `crates/obsbot-device/src/transport/uvc.rs` +- Create: `crates/obsbot-device/src/transport/mod.rs`, `crates/obsbot-device/src/enumeration.rs` +- Modify: `crates/obsbot-device/src/lib.rs` (the group `pub mod device; pub mod registry;` + only) + +**Interfaces:** + +- Consumes: Task 2's `obsbot_device::device` and `obsbot_device::registry`. +- Produces: `obsbot_device::transport::uvc::{Device, CtrlRange, XU_SIZE}`, + `obsbot_device::enumeration::{Detected, detect}`; the old path + `obsbot_device::device::{CtrlRange, Detected, Device, detect}` stays valid through an inline + re-export module until Task 7. + +- [ ] **Step 1: Branch from Task 2, record the base** + +```bash +git switch -c groundwork/transport origin/groundwork/workspace +export CARGO_TARGET_DIR="$SCRATCH/target-groundwork-transport" +git rev-parse HEAD > "$SCRATCH/base-rev.txt" +scripts/groundwork/test-names.sh HEAD > "$SCRATCH/names-base.txt" +``` + +- [ ] **Step 2: Move and split.** Save the script below as `$SCRATCH/split-device.py` (not in + the repository) and run it from the repository root. + +```bash +mkdir -p crates/obsbot-device/src/transport +git mv crates/obsbot-device/src/device.rs crates/obsbot-device/src/transport/uvc.rs +python3 -I "$SCRATCH/split-device.py" +``` + +```python +"""Splits crates/obsbot-device/src/transport/uvc.rs (the 0.13 device.rs, moved there with git +mv) into uvc.rs (the device and its ioctls) and enumeration.rs (finding the camera). Every +block is copied unchanged. Run from the repository root: python3 -I split-device.py""" + +D = "crates/obsbot-device/src" +UVC = D + "/transport/uvc.rs" +L = open(UVC).read().split("\n") + +# (line, text the line starts with) in device.rs as on main 66287b5 +ANCHORS = { + 12: "use crate::error::{Error, Result};", + 13: "use crate::registry::{self, Model};", + 16: "use nix::{ioctl_read, ioctl_readwrite};", + 17: "use std::fs::{self, File};", + 19: "use std::path::PathBuf;", + 32: "const V4L2_CAP_META_CAPTURE", + 44: "#[repr(C)]", + 46: "struct V4l2Capability", + 54: "}", + 78: "ioctl_read!(vidioc_querycap", + 92: "/// A camera found by enumeration", + 99: "}", + 122: "fn card_name", + 134: "}", + 136: "/// Finds a supported camera", + 172: "}", + 174: "/// The first supported camera", + 182: "}", + 276: "#[cfg(test)]", + 278: " use super::*;", + 312: " #[test]", + 313: " fn by_id_detection_does_not_depend_on_directory_order", + 325: " }", + 326: "}", +} +for n, start in ANCHORS.items(): + assert L[n - 1].startswith(start), f"device.rs line {n}: {L[n - 1]!r}" +assert len(L) == 327 and L[326] == "", "device.rs must have 326 lines" + +MOVED = [ + (32, 32), + (44, 54), + (78, 78), + (92, 99), + (122, 134), + (136, 172), + (174, 182), + (312, 325), +] + + +def R(a, b): + return L[a - 1 : b] + + +enumeration = [ + "// SPDX-License-Identifier: GPL-3.0-or-later", + "// Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5.", + "", + "//! Finding a supported camera without talking to it: `/dev/v4l/by-id` links first, the V4L2", + "//! card name as fallback.", + "", + "use crate::registry::{self, Model};", + "use nix::ioctl_read;", + "use std::fs::{self, File};", + "use std::os::fd::AsRawFd;", + "use std::path::PathBuf;", +] +for a, b in MOVED[:-1]: + enumeration += [""] + R(a, b) +enumeration += ( + ["", "#[cfg(test)]", "mod tests {", " use super::*;", ""] + + R(312, 325) + + ["}", ""] +) +open(D + "/enumeration.rs", "w").write("\n".join(enumeration)) + +drop = set() +for a, b in MOVED: + drop.update(range(a, b + 1)) +drop.add(311) # the blank line before the moved test +uvc = [line for n, line in enumerate(L, 1) if n not in drop] +text = "\n".join(uvc) +old_uses = ( + "use crate::error::{Error, Result};\nuse crate::registry::{self, Model};\n" + "use nix::errno::Errno;\nuse nix::fcntl::{Flock, FlockArg};\n" + "use nix::{ioctl_read, ioctl_readwrite};\nuse std::fs::{self, File};\n" + "use std::os::fd::AsRawFd;\nuse std::path::PathBuf;\n" +) +new_uses = ( + "use crate::enumeration::{Detected, detect};\nuse crate::error::{Error, Result};\n" + "use nix::errno::Errno;\nuse nix::fcntl::{Flock, FlockArg};\nuse nix::ioctl_readwrite;\n" + "use std::fs::File;\nuse std::os::fd::AsRawFd;\n" +) +assert text.count(old_uses) == 1 +text = text.replace(old_uses, new_uses) +# the remaining tests of uvc.rs remove their lock file with fs::remove_file +assert text.count("mod tests {\n use super::*;\n") == 1 +text = text.replace( + "mod tests {\n use super::*;\n", + "mod tests {\n use super::*;\n use std::fs;\n", +) +open(UVC, "w").write(text) +``` + +Expected: no output (every anchor matched). If an assertion fails, `main` changed `device.rs`: +find the anchors with `grep -n`, correct the numbers and say so in the pull request. + +- [ ] **Step 3: Write `crates/obsbot-device/src/transport/mod.rs`** + +```rust +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Access to the camera's USB device: extension-unit queries and V4L2 controls. + +pub mod uvc; +``` + +- [ ] **Step 4: Replace the group in `lib.rs`** + +The two lines `pub mod device;` and `pub mod registry;` become: + +```rust +pub mod enumeration; +pub mod registry; +pub mod transport; +/// The 0.13 path of the device module, until Task 7 of the groundwork plan removes it. +pub mod device { + pub use crate::enumeration::{Detected, detect}; + pub use crate::transport::uvc::{CtrlRange, Device}; +} +``` + +- [ ] **Step 5: Gate.** Run the gate block of the Guards section. Expected: no output from + fmt, clippy, shellcheck and actionlint; five `test result: ok` lines with 118, 1, 25, 2 + and 0 passed tests (on top of 66287b5). + +- [ ] **Step 6: Each ioctl and layout of `device.rs` exists exactly once** + +```bash +D=crates/obsbot-device/src +grep -rn 'ioctl_read!\|ioctl_readwrite!\|^struct V4l2\|^struct UvcXu' crates | wc -l +grep -n 'ioctl_read!\|ioctl_readwrite!\|^struct V4l2\|^struct UvcXu' $D/enumeration.rs $D/transport/uvc.rs | sed 's/:[0-9]*:/: /' +``` + +Expected: `11` in the whole tree (the six ioctls and five layouts of 0.13: these nine and the +on-air probe's `vidioc_reqbufs` with `V4l2RequestBuffers`), and from the second grep exactly: + +```text +crates/obsbot-device/src/enumeration.rs: struct V4l2Capability { +crates/obsbot-device/src/enumeration.rs: ioctl_read!(vidioc_querycap, b'V', 0, V4l2Capability); +crates/obsbot-device/src/transport/uvc.rs: struct UvcXuControlQuery { +crates/obsbot-device/src/transport/uvc.rs: struct V4l2Control { +crates/obsbot-device/src/transport/uvc.rs: struct V4l2QueryCtrl { +crates/obsbot-device/src/transport/uvc.rs: ioctl_readwrite!(uvcioc_ctrl_query, b'u', 0x21, UvcXuControlQuery); +crates/obsbot-device/src/transport/uvc.rs: ioctl_readwrite!(vidioc_g_ctrl, b'V', 27, V4l2Control); +crates/obsbot-device/src/transport/uvc.rs: ioctl_readwrite!(vidioc_s_ctrl, b'V', 28, V4l2Control); +crates/obsbot-device/src/transport/uvc.rs: ioctl_readwrite!(vidioc_queryctrl, b'V', 36, V4l2QueryCtrl); +``` + +- [ ] **Step 7: Commit, guards, pull request** + +```bash +git add -A +git commit -F - <<'EOF' +Split device.rs into transport/uvc.rs and enumeration.rs + +The device, its ioctls and the extension-unit access move to transport/uvc.rs; finding the +camera (by-id links, card name) moves to enumeration.rs with its test. Every block is copied +unchanged. lib.rs keeps the old device path as a re-export until the final path change. + +Co-Authored-By: +EOF +scripts/groundwork/test-names.sh HEAD > "$SCRATCH/names-head.txt" +LC_ALL=C comm -3 "$SCRATCH/names-base.txt" "$SCRATCH/names-head.txt" +scripts/groundwork/move-check.sh "$(cat "$SCRATCH/base-rev.txt")" HEAD +``` + +Expected: `comm` prints nothing. **Expected move check:** + +```text +> #[cfg(test)] +> // Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5. +> // SPDX-License-Identifier: GPL-3.0-or-later +> // SPDX-License-Identifier: GPL-3.0-or-later +> //! Access to the camera's USB device: extension-unit queries and V4L2 controls. +> //! Finding a supported camera without talking to it: `/dev/v4l/by-id` links first, the V4L2 +> //! card name as fallback. +> /// The 0.13 path of the device module, until Task 7 of the groundwork plan removes it. +> mod tests { +> pub mod device { +> } +> } +``` + +Pull request into `rebuild` titled "Groundwork: split device.rs into transport and +enumeration", stacked on Task 2's. **Guards:** G1, G2, G3, G4, G6, G7. + +--- + +### Task 5: `models/tiny2lite` (commands, presets, status) + +**Files:** + +- Move: `crates/obsbot-device/src/{commands,presets,status}.rs` → + `crates/obsbot-device/src/models/tiny2lite/` +- Create: `crates/obsbot-device/src/models/mod.rs`, + `crates/obsbot-device/src/models/tiny2lite/mod.rs` +- Modify: `crates/obsbot-device/src/lib.rs` (the group `pub mod commands; pub mod presets; + pub mod status;` only) + +**Interfaces:** + +- Consumes: Task 2's flat `obsbot_device::{commands, presets, status}`. +- Produces: `obsbot_device::models::tiny2lite::{commands, presets, status}`; the old paths stay + valid through `pub use models::tiny2lite::{commands, presets, status};` until Task 7. + +- [ ] **Step 1: Branch from Task 2, record the base** + +```bash +git switch -c groundwork/tiny2lite-modules origin/groundwork/workspace +export CARGO_TARGET_DIR="$SCRATCH/target-groundwork-tiny2lite-modules" +git rev-parse HEAD > "$SCRATCH/base-rev.txt" +scripts/groundwork/test-names.sh HEAD > "$SCRATCH/names-base.txt" +``` + +- [ ] **Step 2: Move** + +```bash +mkdir -p crates/obsbot-device/src/models/tiny2lite +for f in commands presets status; do + git mv "crates/obsbot-device/src/$f.rs" "crates/obsbot-device/src/models/tiny2lite/$f.rs" +done +``` + +- [ ] **Step 3: Write the two module files** + +`crates/obsbot-device/src/models/mod.rs`: + +```rust +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The camera models, one module each. + +pub mod tiny2lite; +``` + +`crates/obsbot-device/src/models/tiny2lite/mod.rs`: + +```rust +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The OBSBOT Tiny 2 Lite (USB 3564:fef9). + +pub mod commands; +pub mod presets; +pub mod status; +``` + +- [ ] **Step 4: Replace the group in `lib.rs`** + +The three lines `pub mod commands;`, `pub mod presets;`, `pub mod status;` become: + +```rust +pub mod models; +pub use models::tiny2lite::{commands, presets, status}; +``` + +- [ ] **Step 5: Gate.** Run the gate block of the Guards section. Expected: no output from + fmt, clippy, shellcheck and actionlint; five `test result: ok` lines with 118, 1, 25, 2 + and 0 passed tests (on top of 66287b5). + +- [ ] **Step 6: Commit, guards, pull request** + +```bash +git add -A +git commit -F - <<'EOF' +Move the Tiny 2 Lite command builders and decoders to models/tiny2lite + +commands.rs, presets.rs and status.rs move unchanged, with their tests; lib.rs keeps their old +paths as re-exports until the final path change of the groundwork stage. + +Co-Authored-By: +EOF +scripts/groundwork/test-names.sh HEAD > "$SCRATCH/names-head.txt" +LC_ALL=C comm -3 "$SCRATCH/names-base.txt" "$SCRATCH/names-head.txt" +scripts/groundwork/move-check.sh "$(cat "$SCRATCH/base-rev.txt")" HEAD +``` + +Expected: `comm` prints nothing. **Expected move check:** + +```text +> // SPDX-License-Identifier: GPL-3.0-or-later +> // SPDX-License-Identifier: GPL-3.0-or-later +> //! The OBSBOT Tiny 2 Lite (USB 3564:fef9). +> //! The camera models, one module each. +``` + +Pull request into `rebuild` titled "Groundwork: move the Tiny 2 Lite builders and decoders to +models/tiny2lite", stacked on Task 2's. **Guards:** G1, G2, G3, G4, G6, G7. + +--- + +### Task 6: Split `camera.rs` + +Starts after Tasks 1 to 5 have landed on `rebuild`. + +**Files:** + +- Move: `crates/obsbot-device/src/camera.rs` → `crates/obsbot-device/src/models/tiny2lite/camera.rs` + (then rewritten by the split), `crates/obsbot-device/src/state.rs` → + `crates/obsbot-device/src/legacy/state.rs` +- Create: `crates/obsbot-device/src/family/exchange.rs`, + `crates/obsbot-device/src/legacy/mod.rs`, `crates/obsbot-device/src/legacy/full_status.rs`, + `crates/obsbot-device/src/legacy/current_pose.rs` +- Modify: `crates/obsbot-device/src/lib.rs` (the group `pub mod camera; pub mod error; pub mod + state;`), `crates/obsbot-device/src/family/mod.rs`, + `crates/obsbot-device/src/models/tiny2lite/mod.rs`, `crates/obsbot4linux/src/cli.rs` and + `crates/obsbot4linux/src/daemon.rs` (the `use` lines that name `camera`), + `docs/center-parity.md` (two paths) + +**Interfaces:** + +- Consumes: `crate::family::frame::{Answer, Sequence, parse_answer, query_frame}`, + `crate::transport::uvc::{Device, CtrlRange}`, `crate::models::tiny2lite::{commands, presets, + status}`, `crate::family::streaming` (Tasks 3 to 5). +- Produces: `obsbot_device::family::exchange::{Camera, PAIR_GAP}` (`Camera::open() -> + Result`; `pub(crate)` fields `dev: Device`, `seq: &'static Sequence`; `pub(crate)` + methods `short(&self, &[u8]) -> Result<()>`, `framed(&self, &[u8]) -> Result<()>`, + `pair(&self, [[u8; 60]; 2]) -> Result<()>`, `query(&self, u16) -> Result>`, + `query_to(&self, u8, u16) -> Result>`, `answer(&self, u16, u16) -> Result>`); + `obsbot_device::models::tiny2lite::camera::{Identity, LivePose, Zone, Hand, HandArea, + Framing, Offset, ImageControl, IMAGE_CONTROLS, ZOOM_ABSOLUTE}` and every flow method of + `Camera`; `obsbot_device::legacy::full_status::{CAPABILITIES, FullStatus, Limits, LIMITS, + StateView, ZoneInfo, Zoom}` with `Camera::{zone_info, full_status, full_status_with}`; + `obsbot_device::legacy::current_pose` with `Camera::current_pose` (`pub(crate)`); + `obsbot_device::legacy::state` (old path `obsbot_device::state` kept as a re-export until + Task 7). + +- [ ] **Step 1: Branch from `rebuild`, record the base** + +```bash +git fetch origin +git switch -c groundwork/camera-split origin/rebuild +export CARGO_TARGET_DIR="$SCRATCH/target-groundwork-camera-split" +git rev-parse HEAD > "$SCRATCH/base-rev.txt" +scripts/groundwork/test-names.sh HEAD > "$SCRATCH/names-base.txt" +``` + +- [ ] **Step 2: Move, then split.** Save the script below as `$SCRATCH/split-camera.py` (not + in the repository) and run it from the repository root. + +```bash +mkdir -p crates/obsbot-device/src/legacy +git mv crates/obsbot-device/src/state.rs crates/obsbot-device/src/legacy/state.rs +git mv crates/obsbot-device/src/camera.rs crates/obsbot-device/src/models/tiny2lite/camera.rs +python3 -I "$SCRATCH/split-camera.py" +``` + +```python +"""Splits crates/obsbot-device/src/models/tiny2lite/camera.rs (the 0.13 camera.rs, moved there +with git mv) into four files. Every block is copied unchanged; the only edits are the visibility +widenings listed in VIS. Run from the repository root: python3 -I split-camera.py""" + +import os + +D = "crates/obsbot-device/src" +SRC = os.path.join(D, "models/tiny2lite/camera.rs") +L = open(SRC).read().split("\n") + +# (line, text the line starts with): the boundaries of every block below, in camera.rs as on +# main 66287b5. If one does not match, main changed camera.rs: find the anchors with grep -n, +# fix the numbers here and in BLOCKS, and say so in the pull request. +ANCHORS = { + 20: "const REPLY_GAP", + 23: "const PAIR_GAP", + 25: "/// Commands obsbot4linux supports", + 76: "];", + 78: "/// UVC controls", + 100: "const ZOOM_ABSOLUTE", + 101: "const PAN_ABSOLUTE", + 102: "const TILT_ABSOLUTE", + 104: "#[derive(Debug, Clone, Serialize)]", + 109: "}", + 111: "#[derive(Debug, Serialize)]", + 118: "}", + 120: "/// Degrees", + 138: "}", + 140: "#[derive(Debug, Serialize)]", + 184: "}", + 186: "/// `limits`", + 199: "};", + 201: "/// `state`", + 210: "}", + 212: "/// Zone tracking", + 314: "}", + 316: "/// The rarely", + 324: "}", + 326: "/// What does not", + 331: "}", + 333: "/// The microphone", + 374: "}", + 376: "/// One sequence", + 381: "}", + 383: "pub struct Camera", + 386: "}", + 388: "impl Camera {", + 389: " pub fn open", + 394: " }", + 396: " fn short", + 436: " }", + 438: " // --- reading ---", + 624: " }", + 626: " pub fn zone_info", + 634: " }", + 636: " pub fn full_status", + 638: " }", + 640: " /// Like [`Camera::full_status`]", + 707: " }", + 709: " // --- simple settings ---", + 1226: " }", + 1228: " /// The pose to save", + 1246: " }", + 1248: " /// Saves a preset", + 1297: " }", + 1298: "}", + 1300: "#[cfg(test)]", + 1301: "mod full_status_tests {", + 1466: "}", + 1468: "#[cfg(test)]", + 1665: "}", +} +for n, start in ANCHORS.items(): + assert L[n - 1].startswith(start), f"camera.rs line {n}: {L[n - 1]!r}" +assert len(L) == 1666 and L[1665] == "", "camera.rs must have 1665 lines" + +# The only edits: items another file now uses become pub(crate). +VIS = [ + ("const PAIR_GAP", "pub(crate) const PAIR_GAP"), + (" dev: Device,", " pub(crate) dev: Device,"), + (" seq: &'static Sequence,", " pub(crate) seq: &'static Sequence,"), + (" fn short(", " pub(crate) fn short("), + (" fn framed(", " pub(crate) fn framed("), + (" fn pair(", " pub(crate) fn pair("), + (" fn query(", " pub(crate) fn query("), + (" fn query_to(", " pub(crate) fn query_to("), + (" fn answer(", " pub(crate) fn answer("), + ("const ZOOM_ABSOLUTE", "pub(crate) const ZOOM_ABSOLUTE"), + (" fn current_pose(", " pub(crate) fn current_pose("), +] + + +def R(a, b): + out = [] + for line in L[a - 1 : b]: + for old, new in VIS: + if line.startswith(old): + line = new + line[len(old) :] + out.append(line) + return out + + +SPDX = "// SPDX-License-Identifier: GPL-3.0-or-later" +EUPL = "// Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5." + + +def write(path, lines): + with open(os.path.join(D, path), "w") as f: + f.write("\n".join(lines) + "\n") + + +write( + "family/exchange.rs", + [ + SPDX, + EUPL, + "", + "//! The exchange with the camera's extension unit: short commands, framed commands and the", + "//! polling for their answers, with one sequence number counter for the whole process.", + "", + "use crate::error::{Error, Result};", + "use crate::family::frame::{Answer, Sequence, parse_answer, query_frame};", + "use crate::transport::uvc::Device;", + "use std::sync::OnceLock;", + "use std::thread::sleep;", + "use std::time::Duration;", + "", + ] + + R(20, 23) + + [""] + + R(376, 381) + + [""] + + R(383, 386) + + ["", "impl Camera {"] + + R(389, 394) + + [""] + + R(396, 436) + + ["}"], +) + +write( + "legacy/full_status.rs", + [ + SPDX, + EUPL, + "", + "//! The status of 0.13 (`status --json` and the daemon's state): the full status, its", + "//! capability list and limits, and the readbacks the daemon keeps between full reads.", + "", + "use crate::error::{Error, Result};", + "use crate::family::exchange::Camera;", + "use crate::family::streaming;", + "use crate::legacy::state::{Startup, State};", + "use crate::models::tiny2lite::camera::{", + " Framing, Hand, Identity, ImageControl, LivePose, ZOOM_ABSOLUTE, Zone,", + "};", + "use crate::models::tiny2lite::commands::*;", + "use crate::models::tiny2lite::presets::{MAX_ZOOM, Preset};", + "use crate::models::tiny2lite::status::{Focus, ModelInfo, Status, Target, Tracking};", + "use serde::Serialize;", + "use std::collections::BTreeMap;", + "", + ] + + R(25, 76) + + [""] + + R(111, 118) + + [""] + + R(140, 184) + + [""] + + R(186, 199) + + [""] + + R(201, 210) + + [""] + + R(316, 324) + + ["", "impl Camera {"] + + R(626, 634) + + [""] + + R(636, 638) + + [""] + + R(640, 707) + + ["}", ""] + + R(1300, 1301) + + [ + " use crate::models::tiny2lite::camera::{HandArea, Offset};", + " use crate::models::tiny2lite::presets::{Pose, ZoneView};", + "", + ] + + R(1302, 1466), +) + +write( + "legacy/current_pose.rs", + [ + SPDX, + EUPL, + "", + "//! The pose 0.13 saves when none is given: the remembered gimbal pose, else the one V4L2", + "//! last set, with the live zoom factor.", + "", + "use crate::error::Result;", + "use crate::family::exchange::Camera;", + "use crate::legacy::state::State;", + "use crate::models::tiny2lite::presets::{MAX_ZOOM, Pose};", + "", + ] + + R(101, 102) + + ["", "impl Camera {"] + + R(1228, 1246) + + ["}"], +) + +write( + "models/tiny2lite/camera.rs", + L[0:4] + + [ + "", + "use crate::error::{Error, Result};", + "use crate::family::exchange::{Camera, PAIR_GAP};", + "use crate::legacy::state::{Startup, State};", + "use crate::models::tiny2lite::commands::{self as cmd, *};", + "use crate::models::tiny2lite::presets::{", + " self, MAX_ZOOM, Pose, Preset, StartupActions, StartupAi, ZoneView,", + "};", + "use crate::models::tiny2lite::status::{", + " Focus, ModelInfo, Status, Target, Tracking, firmware_version,", + "};", + "use crate::transport::uvc::CtrlRange;", + "use serde::Serialize;", + "use std::collections::BTreeMap;", + "use std::thread::sleep;", + "", + ] + + R(78, 100) + + [""] + + R(104, 109) + + [""] + + R(120, 138) + + [""] + + R(212, 314) + + [""] + + R(326, 331) + + [""] + + R(333, 374) + + ["", "impl Camera {"] + + R(438, 624) + + [""] + + R(709, 1226) + + [""] + + R(1248, 1297) + + ["}", ""] + + R(1468, 1665), +) +``` + +Expected: no output. If an assertion fails, `main` changed `camera.rs`: find the anchors with +`grep -n`, correct the numbers and say so in the pull request. + +- [ ] **Step 3: Write `crates/obsbot-device/src/legacy/mod.rs`** + +```rust +// SPDX-License-Identifier: GPL-3.0-or-later + +//! What 0.13 keeps around the camera and what only its daemon and CLI use. The rebuild does not +//! carry it over (spec section 3.2); the 0.13 daemon and CLI use it until they are replaced. + +pub mod current_pose; +pub mod full_status; +pub mod state; +``` + +- [ ] **Step 4: Module declarations** + +In `crates/obsbot-device/src/lib.rs` the three lines `pub mod camera;`, `pub mod error;`, +`pub mod state;` become: + +```rust +pub mod error; +pub mod legacy; +pub use legacy::state; +``` + +In `crates/obsbot-device/src/family/mod.rs` add `pub mod exchange;` before `pub mod frame;`. +In `crates/obsbot-device/src/models/tiny2lite/mod.rs` add `pub mod camera;` before +`pub mod commands;`. + +- [ ] **Step 5: The binary's imports of `camera`, and the docs** + +In `crates/obsbot4linux/src/cli.rs` the line +`use obsbot_device::camera::{Camera, Identity};` becomes + +```rust +use obsbot_device::family::exchange::Camera; +use obsbot_device::models::tiny2lite::camera::Identity; +``` + +In `crates/obsbot4linux/src/daemon.rs` the line +`use obsbot_device::camera::{CAPABILITIES, Camera, Identity, ZoneInfo};` becomes + +```rust +use obsbot_device::family::exchange::Camera; +use obsbot_device::legacy::full_status::{CAPABILITIES, ZoneInfo}; +use obsbot_device::models::tiny2lite::camera::Identity; +``` + +```bash +sed -i 's#`src/camera.rs`#`crates/obsbot-device/src/legacy/full_status.rs`#' docs/center-parity.md +grep -rn 'obsbot_device::camera\|crate::camera' crates +``` + +Expected from the grep: nothing. + +- [ ] **Step 6: Gate** + +```bash +nice -n 19 cargo fmt +nice -n 19 cargo clippy --workspace --all-targets --locked -j 3 -- -D warnings +nice -n 19 cargo test --workspace --locked -j 3 2>&1 | grep 'test result' +``` + +Expected: no clippy output (no unused import, no dead code); the same five `test result` +lines as before. + +- [ ] **Step 7: Timing constants and the sequence counter exist once, unchanged** + +```bash +grep -rn 'static SEQ\|const REPLY_GAP\|const REPLY_ATTEMPTS\|const PAIR_GAP\|const LOCK_WAIT' crates +``` + +Expected, exactly these five lines: + +```text +crates/obsbot-device/src/family/exchange.rs:…:const REPLY_GAP: Duration = Duration::from_millis(50); +crates/obsbot-device/src/family/exchange.rs:…:const REPLY_ATTEMPTS: usize = 20; +crates/obsbot-device/src/family/exchange.rs:…:pub(crate) const PAIR_GAP: Duration = Duration::from_millis(100); +crates/obsbot-device/src/family/exchange.rs:…: static SEQ: OnceLock = OnceLock::new(); +crates/obsbot-device/src/transport/uvc.rs:…:const LOCK_WAIT: Duration = Duration::from_secs(5); +``` + +- [ ] **Step 8: Commit, guards, pull request** + +```bash +git add -A +git commit -F - <<'EOF' +Split camera.rs into the exchange, the Tiny 2 Lite flows and the 0.13 status + +The exchange with the extension unit (short and framed writes, answer polling, the sequence +counter) moves to family/exchange.rs; the Tiny 2 Lite reads and writes stay as +models/tiny2lite/camera.rs; the 0.13 status shape, its capability list and the remembered +pose move to legacy, with state.rs. Every block is copied unchanged; items another file now +uses become pub(crate). + +Co-Authored-By: +EOF +scripts/groundwork/test-names.sh HEAD > "$SCRATCH/names-head.txt" +LC_ALL=C comm -3 "$SCRATCH/names-base.txt" "$SCRATCH/names-head.txt" +scripts/groundwork/move-check.sh "$(cat "$SCRATCH/base-rev.txt")" HEAD +git diff -M --stat "$(cat "$SCRATCH/base-rev.txt")" HEAD +``` + +Expected: `comm` prints nothing; the stat shows `{ => legacy}/state.rs | 0` and +`{ => models/tiny2lite}/camera.rs` as a rename. **Expected move check:** + +```text +> // Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5. +> // Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5. +> // Derived from Tiny4Linux (EUPL-1.2), used under EUPL-1.2 Article 5. +> // SPDX-License-Identifier: GPL-3.0-or-later +> // SPDX-License-Identifier: GPL-3.0-or-later +> // SPDX-License-Identifier: GPL-3.0-or-later +> // SPDX-License-Identifier: GPL-3.0-or-later +> //! The exchange with the camera's extension unit: short commands, framed commands and the +> //! The pose 0.13 saves when none is given: the remembered gimbal pose, else the one V4L2 +> //! The status of 0.13 (`status --json` and the daemon's state): the full status, its +> //! What 0.13 keeps around the camera and what only its daemon and CLI use. The rebuild does not +> //! capability list and limits, and the readbacks the daemon keeps between full reads. +> //! carry it over (spec section 3.2); the 0.13 daemon and CLI use it until they are replaced. +> //! last set, with the live zoom factor. +> //! polling for their answers, with one sequence number counter for the whole process. +> impl Camera { +> impl Camera { +> impl Camera { +> } +> } +> } +``` + +Pull request into `rebuild` titled "Groundwork: split camera.rs". **Guards:** G1, G2, G3, G4, +G6, G7. + +--- + +### Task 7: Final module paths + +**Files:** + +- Modify: `crates/obsbot-device/src/lib.rs`, `crates/obsbot-device/src/models/tiny2lite/ + {commands,presets,status,camera}.rs`, `crates/obsbot-device/src/legacy/state.rs`, + `crates/obsbot-device/src/registry.rs` (doc comment), + `crates/obsbot-device/tests/byte_guard.rs`, `crates/obsbot4linux/src/{cli,client,daemon}.rs` + +**Interfaces:** + +- Consumes: the paths of Tasks 3 to 6. +- Produces: the final module tree of "Target layout"; the old paths `obsbot_device::{frame, + streaming, device, commands, presets, status, state}` no longer exist. + +- [ ] **Step 1: Branch from Task 6, record the base** + +```bash +git switch -c groundwork/final-paths origin/groundwork/camera-split +export CARGO_TARGET_DIR="$SCRATCH/target-groundwork-final-paths" +git rev-parse HEAD > "$SCRATCH/base-rev.txt" +scripts/groundwork/test-names.sh HEAD > "$SCRATCH/names-base.txt" +``` + +- [ ] **Step 2: Write the final `crates/obsbot-device/src/lib.rs`** + +```rust +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Control library for the OBSBOT Tiny 2 Lite. + +pub mod enumeration; +pub mod error; +pub mod family; +pub mod legacy; +pub mod models; +pub mod registry; +pub mod transport; +``` + +- [ ] **Step 3: Rewrite the old paths** + +```bash +D=crates/obsbot-device/src +B=crates/obsbot4linux/src +sed -i -e 's/\[`crate::frame`\]/[`crate::family::frame`]/' -e 's/^use crate::frame::/use crate::family::frame::/' \ + $D/models/tiny2lite/commands.rs +sed -i -e 's/^use crate::commands::/use crate::models::tiny2lite::commands::/' \ + -e 's/^use crate::frame::/use crate::family::frame::/' $D/models/tiny2lite/presets.rs +sed -i 's/^use crate::commands::/use crate::models::tiny2lite::commands::/' \ + $D/models/tiny2lite/status.rs $D/legacy/state.rs +sed -i 's/^ use crate::commands::AutoZoomSpeed;/ use crate::models::tiny2lite::commands::AutoZoomSpeed;/' \ + $D/models/tiny2lite/camera.rs +sed -i 's/`device::detect` feeds it/`enumeration::detect` feeds it/' $D/registry.rs +sed -i -e 's/^use obsbot_device::commands::/use obsbot_device::models::tiny2lite::commands::/' \ + -e 's/^use obsbot_device::frame::/use obsbot_device::family::frame::/' \ + -e 's/^use obsbot_device::presets::/use obsbot_device::models::tiny2lite::presets::/' \ + crates/obsbot-device/tests/byte_guard.rs +sed -i -e 's/^use obsbot_device::commands::/use obsbot_device::models::tiny2lite::commands::/' \ + -e 's/^use obsbot_device::device::Detected;/use obsbot_device::enumeration::Detected;/' \ + -e 's/^use obsbot_device::presets::/use obsbot_device::models::tiny2lite::presets::/' \ + -e 's/obsbot_device::device::detect()/obsbot_device::enumeration::detect()/' $B/cli.rs +sed -i 's/obsbot_device::state::path()/obsbot_device::legacy::state::path()/' $B/client.rs +sed -i -e 's/^use obsbot_device::state;/use obsbot_device::legacy::state;/' \ + -e 's/^use obsbot_device::streaming;/use obsbot_device::family::streaming;/' $B/daemon.rs +nice -n 19 cargo fmt +grep -rnE '(crate|obsbot_device)::(camera|commands|device|frame|presets|state|status|streaming)\b' crates +``` + +Expected from the grep: nothing. + +- [ ] **Step 4: Gate.** Run the gate block of the Guards section. Expected: no output from + fmt, clippy, shellcheck and actionlint; five `test result: ok` lines with 118, 1, 25, 2 + and 0 passed tests (on top of 66287b5). + +- [ ] **Step 5: Release build and help text** (G3 by hand once more on the release profile) + +```bash +nice -n 19 cargo build --release --locked -j 3 +"$CARGO_TARGET_DIR/release/obsbot4linux" --version +``` + +Expected: `obsbot4linux 0.13.2`. + +- [ ] **Step 6: Commit, guards, pull request** + +```bash +git add -A +git commit -F - <<'EOF' +Use the final module paths and drop the re-exports + +Every use line and the two module paths in code name the modules where they live now; +lib.rs declares only the final modules. No behaviour change. + +Co-Authored-By: +EOF +scripts/groundwork/test-names.sh HEAD > "$SCRATCH/names-head.txt" +LC_ALL=C comm -3 "$SCRATCH/names-base.txt" "$SCRATCH/names-head.txt" +scripts/groundwork/move-check.sh "$(cat "$SCRATCH/base-rev.txt")" HEAD +``` + +Expected: `comm` prints nothing. **Expected move check:** + +```text +< //! selector 2 (see [`crate::frame`]). Only ids from the protocol reference are used. +> //! selector 2 (see [`crate::family::frame`]). Only ids from the protocol reference are used. +< //! work so it can be tested without a camera; `device::detect` feeds it from `/dev`. +> //! work so it can be tested without a camera; `enumeration::detect` feeds it from `/dev`. +< /// The 0.13 path of the device module, until Task 7 of the groundwork plan removes it. +< let Some(found) = obsbot_device::device::detect() else { +> let Some(found) = obsbot_device::enumeration::detect() else { +< let log = obsbot_device::state::path().with_file_name("daemon.log"); +> let log = obsbot_device::legacy::state::path().with_file_name("daemon.log"); +< pub mod device { +< } +``` + +Pull request into `rebuild` titled "Groundwork: final module paths", stacked on Task 6's. +**Guards:** G1, G2, G3, G4, G6, G7. + +--- + +### Task 8: One mutation pass over the carried modules + +**Files:** + +- Modify (test modules only, `#[cfg(test)]` code): any file under + `crates/obsbot-device/src/{family,transport,models,registry.rs,enumeration.rs}` and + `crates/obsbot-device/src/legacy/full_status.rs` whose survivor a new test kills +- No file outside `#[cfg(test)]` code changes. + +**Interfaces:** + +- Consumes: the final tree of Task 7. +- Produces: new test functions inside existing test modules (names describe the behaviour + they pin); the run record in the pull request description. + +- [ ] **Step 1: Branch from Task 7, install the tool** + +```bash +git switch -c groundwork/mutants origin/groundwork/final-paths +export CARGO_TARGET_DIR="$SCRATCH/target-groundwork-mutants" +git rev-parse HEAD > "$SCRATCH/base-rev.txt" +scripts/groundwork/test-names.sh HEAD > "$SCRATCH/names-base.txt" +nice -n 19 cargo install --locked cargo-mutants@27.1.0 +cargo mutants --version +``` + +Expected: `cargo-mutants 27.1.0`. + +- [ ] **Step 2: List the scope** + +```bash +SCOPE=(--package obsbot-device --test-workspace=true + --exclude crates/obsbot-device/src/legacy/state.rs + --exclude crates/obsbot-device/src/legacy/current_pose.rs) +cargo mutants "${SCOPE[@]}" --list | wc -l +cargo mutants "${SCOPE[@]}" --list | cut -d: -f1 | sort | uniq -c +``` + +Expected on top of 66287b5: `847`; per file: `enumeration.rs` 16, `family/exchange.rs` 14, +`family/frame.rs` 53, `family/streaming.rs` 23, `legacy/full_status.rs` 5, +`models/tiny2lite/camera.rs` 237, `models/tiny2lite/commands.rs` 208, +`models/tiny2lite/presets.rs` 169, `models/tiny2lite/status.rs` 81, `registry.rs` 5, +`transport/uvc.rs` 36 (other numbers if `main` added code; the run records what it found). + +- [ ] **Step 3: Run the pass** (in place, one job, low priority: in-place mutation needs a + clean tree and touches only this worktree; about 20 minutes) + +```bash +git status --short # must print nothing +timeout 7200 nice -n 19 cargo mutants "${SCOPE[@]}" --in-place --output "$SCRATCH/mutants" 2>&1 | tail -n 1 +git status --short # must print nothing again +``` + +Expected: the baseline test run passes, then a last line +`847 mutants tested in …: N missed, M caught, U unviable` (and timeouts if any). The lists +are in `$SCRATCH/mutants/mutants.out/{missed,caught,timeout,unviable}.txt`. + +- [ ] **Step 4: Give every missed mutant a disposition** + +Each line of `missed.txt` gets exactly one: + +- **K, killed here:** a new test in the module's existing test module. Expected bytes come from + a recording (cite "capture N") or from the layout the code documents, never from running the + code. Rerun that mutant to show it is caught: + `cargo mutants "${SCOPE[@]}" --in-place -F ''` + → `1 mutant tested …: 1 caught` (escape `( ) [ ] { } * + . ?` in the regex). +- **D, needs the device:** a method of `Camera` or `Device` whose effect is an extension-unit + or V4L2 call, or an ioctl structure field. Track A's replay transport covers these. +- **L, 0.13 only:** a write of the remembered state (`State { … }.save()`) inside a flow; not + carried (spec 3.2). +- **E, equivalent:** no input can tell the mutant from the code; one sentence why. +- **M, waits for main:** the named mutants of Step 5 while their test seam is not on `main`. + +A survivor in a function that does no I/O (a builder, decoder, conversion or parser) may only +be K or E. + +- [ ] **Step 5: The named mutants of spec 10.2** + +```bash +F=crates/obsbot-device/src +grep 'delete match arm 0x89 in parse_answer' "$SCRATCH/mutants/mutants.out/caught.txt" +grep -E 'delete - in (tilt_to_pitch_range|pitch_to_tilt_range|HandArea::decode|LivePose::decode|Camera::gimbal)' \ + "$SCRATCH/mutants/mutants.out/caught.txt" "$SCRATCH/mutants/mutants.out/missed.txt" +# a rejected answer handled as stale (hand-made: cargo-mutants cannot delete that arm) +sed -i 's/Some(Answer::Rejected(code)) => return Err(Error::Rejected(command, code)),/Some(Answer::Rejected(_)) => {}/' $F/family/exchange.rs +nice -n 19 cargo test --workspace --locked -j 3 2>&1 | grep -E '^error|test result' +git checkout -- $F/family/exchange.rs +# a rejected tracking query failing the whole read (hand-made) +sed -i 's/Err(Error::NoAnswer(_) | Error::Rejected(..)) => None,/Err(Error::NoAnswer(_)) => None,/' $F/legacy/full_status.rs +nice -n 19 cargo test --workspace --locked -j 3 2>&1 | grep -E '^error|test result' +git checkout -- $F/legacy/full_status.rs +git status --short +``` + +Expected on top of 66287b5: the `0x89` arm deletion is in `caught.txt`; every `delete -` in +the four conversion functions is caught, and `delete - in Camera::gimbal` (the tilt negation +of the gimbal move) is in `missed.txt`; both hand-made mutants compile and give five +`test result: ok` lines (survived); `git status` prints nothing. The three survivors get disposition M and +are listed in the pull request as open items of the stage acceptance (see "After the tasks"). +Once `main` has the seams of "Before Task 1", P3, and `rebuild` is rebased, this step is +repeated and must show all of them caught (a hand-made mutant then gives a `test result: FAILED` line). + +- [ ] **Step 6: Gate and guards** + +Run the gate block of the Guards section. Expected: no output from fmt, clippy, shellcheck and +actionlint; all tests pass, with more of them where K tests were added; +`LC_ALL=C comm -23 "$SCRATCH/names-base.txt" "$SCRATCH/names-head.txt"` prints nothing (no +test lost) and `comm -13` prints exactly the new K tests; G2 and G3 unchanged; +`scripts/groundwork/move-check.sh "$(cat "$SCRATCH/base-rev.txt")" HEAD` prints only lines of +the new tests. + +- [ ] **Step 7: Commit, pull request with the record** + +```bash +git add -A +git commit -F - <<'EOF' +Add the tests that kill mutation survivors in carried code + +One cargo-mutants 27.1.0 pass over obsbot-device (without the 0.13-only state and pose +modules). The pull request records the run and the disposition of every survivor. + +Co-Authored-By: +EOF +``` + +The pull request ("Groundwork: mutation pass over the carried modules", stacked on Task 7's) +records: the command line, the tool version, the commit, the last line of the run, a table of +dispositions with counts (K, D, L, E, M), the full `missed.txt` in a collapsed section with the +disposition at the end of each line, and the three named items with their state. If no +survivor is K, there is no code change and no pull request: the same record goes into the +tracking issue of the stage, and the task ends there. +**Guards:** G1 (names only added), G2, G3, G4, G6, G7. + +--- + +## After the tasks: CS-0 and the 0.14.0 release + +Not tasks of this plan; listed so that nobody releases early. + +1. The named mutants of Task 8, Step 5 are all caught (needs P3 on `main` and a rebase). +2. The camera smoke test CS-0 (spec 10.2, CAM-30) on the real Tiny 2 Lite with a build of the + tip of `rebuild`: `status`, one setting set and read back, one gimbal move, `preset list`. +3. A release pull request into `rebuild`: workspace version 0.14.0, `Cargo.lock`, PKGBUILD + `pkgver`, the CHANGELOG heading. +4. `main` fast-forwards to `rebuild`; tag `v0.14.0`; the release workflow runs the `contents` + job on the tag. + +## Self-review + +- **Spec coverage (10.2 content):** workspace with `[workspace.package]` and + `[workspace.dependencies]` – Task 2; `crates/obsbot-device` with `transport`, `family`, + `models/tiny2lite`, `registry` as plain modules – Tasks 2 to 5 and 7; `camera.rs` split into + transport-side exchange and Tiny 2 Lite flows – Task 6; the 0.13 daemon and CLI unchanged in + `crates/obsbot4linux` – Tasks 2, 6, 7 and G3; byte tests moved with their code – every task, + G1; CI and packaging for the workspace, package metadata in the binary crate – Task 2, G5; + one mutation pass, survivors fixed or documented – Task 8. Acceptance without camera: every + 0.13.2 test present – G1; the diff shows renames and module paths only – G6, G7; `--help` + equals 0.13.2's – G3; eight artifacts with the 0.13 binary – G5; the named mutants – Task 8, + Step 5 (open until P3). Acceptance with camera – "After the tasks". Spec 3.3 licence notes – + the split scripts write the EUPL note into every file that receives code from a noted file + (`enumeration.rs`, `exchange.rs`, `full_status.rs`, `current_pose.rs`, `camera.rs`). +- **Placeholder scan:** the only filled-in values left to the builder are the model name in + the `Co-Authored-By` line, the pull request number in `gh pr checks`, and the regex of a K + rerun, each defined where it appears. +- **Type and name consistency:** the module paths of each task's "Produces" are the ones the + next task's "Consumes" and `sed` lines use (checked by building the whole sequence once in a + scratch copy of `rebuild`: every task's gate, G1, G2, G3 and G6 gave the outputs written + here). +- **Review Focus:** each of the five items has its check in the owning task (Task 6 Step 7, + Task 4 Step 6, Task 2 Steps 11 and 12, G5). From 4534d1d353c867efe3aa2b598ecdd4d38e1137fb Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 08:35:29 +0100 Subject: [PATCH 06/27] Groundwork plan: review fixes Task 1 goes to main, so that a later fix re-records the fixtures in the 0.13 layout; the camera-free fixes of spec 3.2 are listed with their issues, and the camera.rs split waits for the ones that touch camera.rs. Rebasing rebuild needs a decision on who force-pushes (P4). The strip check now fails on an unstripped binary and runs on every package in CI; the gate's shellcheck covers only the new scripts; the split scripts are committed and their output is reproduced in review (G6b), because the move check cannot see an order change; the mutation pass skips the byte guard; Task 2 also compares the feature tree. Decision 4 says that legacy/full_status.rs holds carried read logic that track A ports. Co-Authored-By: Claude Opus 5.5 --- .../2026-11-13-obsbot4linux-groundwork.md | 538 +++++++++++++----- 1 file changed, 384 insertions(+), 154 deletions(-) diff --git a/docs/superpowers/plans/2026-11-13-obsbot4linux-groundwork.md b/docs/superpowers/plans/2026-11-13-obsbot4linux-groundwork.md index 9113985..598267e 100644 --- a/docs/superpowers/plans/2026-11-13-obsbot4linux-groundwork.md +++ b/docs/superpowers/plans/2026-11-13-obsbot4linux-groundwork.md @@ -6,18 +6,16 @@ modules at their final paths in `crates/obsbot-device` and the 0.13 daemon and CLI unchanged on top of them, without changing one byte sent to the camera or one line of CLI output. -**Architecture:** Pure moves on the branch `rebuild`, in eight pull requests. Task 1 adds guards -to the 0.13 layout (a byte guard over every command builder, a CLI help guard, a package -contents check, a test-name list and a move check). Task 2 makes the workspace with the files -moved flat. Tasks 3 to 5 move the carried modules into `family`, `transport` and -`models/tiny2lite` in parallel, keeping the old module paths alive through re-exports. Task 6 -splits `camera.rs`, Task 7 switches every path to the final one and removes the re-exports, -Task 8 runs one mutation pass. Every task leaves a green build and passes every guard. +**Architecture:** Eight pull requests: Task 1 adds guards (byte guard, CLI help guard, package +contents, test names, move check) to the 0.13 layout on `main`; Tasks 2 to 7 are pure moves on +`rebuild` into the workspace, the carried modules going to `family`, `transport`, +`models/tiny2lite` and `legacy` behind re-exports that Task 7 removes; Task 8 runs one mutation +pass. Every task leaves a green build and passes every guard. **Tech Stack:** Rust (edition 2024, `rust-version` 1.88, CI toolchain 1.99.1), cargo workspace (resolver 3), clap 4, nix 0.31, serde; GitHub Actions (`ci.yml`, `release.yml`); cargo-deb 3.8.0, cargo-generate-rpm 0.21.1, makepkg in an Arch container; cargo-mutants 27.1.0; bash, -bsdtar, jq, python3 (only for the two one-off split scripts). +bsdtar, file, jq, python3 (only for the two split scripts). **Spec:** `docs/superpowers/specs/2026-11-01-obsbot4linux-1.0-design.md` (revision 2), sections 3 (architecture), 3.2 (carry-over), 3.3 (licence), 10.1 (branches and rules), 10.2 (stage G), @@ -40,9 +38,11 @@ bsdtar, jq, python3 (only for the two one-off split scripts). artifacts." "There is no libudev dependency." "Packages do not enable the socket unit." (spec 12.1). The package name stays `obsbot4linux` in every format. - The 0.13 daemon and CLI keep working on top of the workspace (spec 10.2). -- `main` is not touched by this plan. Every task is one pull request into `rebuild` with the - `required` check green and a review by someone other than its author (spec 10.1). When `main` - moves, `rebuild` is rebased onto it between pull requests (spec 10.1). +- Task 1 is one pull request into `main`: tests and CI, the class of change spec 10.1 sends to + `main` first. Every other task is one pull request into `rebuild`. Each has the `required` + check green and a review by someone other than its author (spec 10.1). When `main` moves, + `rebuild` is rebased onto it while no pull request into `rebuild` is open (spec 10.1), by the + procedure of "Before Task 1", P4; nobody working on a task force-pushes. - No camera I/O. Nothing in this plan runs the binary except with `--help` or `--version`. The camera smoke test (CS-0) comes after the tasks and gates the release, not a task. - Commits are signed; messages are written through a quoted heredoc and end with the @@ -59,20 +59,29 @@ bsdtar, jq, python3 (only for the two one-off split scripts). 1. **Timing constants and the one sequence counter across the `camera.rs` split.** A reply gap of 50 ms, 20 reply attempts, a pair gap of 100 ms, a lock wait of 5 s and exactly one process-wide `static SEQ` must survive the split; no unit test sees a changed gap or a second - counter. Pinned by the move check (G6) and by Task 6, Step 7 (grep counts). + counter. Pinned by the move check (G6) and by Task 6, Step 7 (grep counts: a grep, not a + test, which is enough for a move). 2. **ioctl definitions and `#[repr(C)]` layouts moved between `transport/uvc.rs` and `enumeration.rs`.** A copy instead of a move, or a changed number, compiles and passes the tests. Pinned by G6 and Task 4, Step 6 (each of the five ioctls and four layouts exists - exactly once, with its 0.13 numbers). + exactly once, with its 0.13 numbers; a grep, not a test). 3. **The release profile.** `[profile.release] strip = true` must move to the workspace root; in a member manifest cargo ignores it and the packages ship an unstripped binary with the - same file list. Pinned by Task 2, Step 12 (`file` says `stripped`). + same file list. Pinned by Task 2, Step 12 (`file -b` ends in `, stripped`, not + `not stripped`) and by the `contents` job of every release run (G5), which checks the binary + in each of the eight packages. 4. **Dependency versions and features.** Two crates unify features differently than one; the - lock file must resolve the same crates at the same versions. Pinned by Task 2, Step 11 - (`cargo metadata` before and after). + lock file must resolve the same crates at the same versions with the same features. Pinned + by Task 2, Step 11 (`cargo metadata` and `cargo tree -e features` before and after). 5. **Package names and metadata.** The deb and rpm name, description, licence and asset paths come from the binary crate's manifest after the move. Pinned by Task 2, Step 11 (package metadata before and after) and by the `contents` job of the release dry run (G5). +6. **An order change inside a `Camera` flow.** The move check compares lines as a multiset: two + swapped statements of a flow (a write and its `sleep(PAIR_GAP)`), a line moved from one + function into another, or two swapped lines of a frame array or a `match` pass G1 to G6, and + no test drives a flow. Pinned by G6b (the splits of Tasks 4 and 6 are made by committed + scripts that copy contiguous line ranges, and the reviewer reproduces their output) and by + reading the rest of the diff with `--color-moved` (G7). --- @@ -101,13 +110,56 @@ These are not tasks of this plan; the coordinator checks them before the first p - **P2. CI on `rebuild`.** `ci.yml` and `release.yml` already run for pull requests into `rebuild` (checked on `origin/main` 66287b5). Nothing to do. -- **P3. Camera-free fixes in carried code land on `main` first** (spec 10.1, 10.2 "Depends - on"). Tasks 1 to 7 can start before they land; each later rebase of `rebuild` onto `main` then - carries them (git follows the renames; a `main` change to `camera.rs` after Task 6 has to be - ported by hand into the split file, noted in the tracking issue). Task 8's named mutants need - three of them (Task 8, Step 5): a test seam for the answer poll (rejected, empty, no answer, - read error), a test for "a rejected tracking query gives `tracking: null`, not a failed read", - and the tilt negation of `gimbal` as a tested pure function. They are not filed yet. +- **P3. Camera-free fixes in carried code land on `main` first** (spec 3.2, 10.1, 10.2 + "Depends on", 10.5). The coordinator files every item below that has no issue yet before + Task 1 starts and lists them all in the tracking issue of the stage. File names are those of + the 0.13 layout. + + | Fix | Issue | Touches | + | --------------------------------------------------------------------------------------------------------------- | ------- | ----------------------------------------------------- | + | Two tests that cannot fail; daemon paths without tests | #47 | `daemon.rs`, `streaming.rs` | + | Answers: check the command id and the header CRC | #61 | `frame.rs` (`camera.rs` if the `answer` call changes) | + | The answer poll fails at once on a read error instead of treating it as "not ready" | to file | `camera.rs` (`answer`) | + | The image setter does not write white-balance temperature or absolute focus while their automatic control is on | to file | `camera.rs` (`set_image`) | + | The on-air state and its applications come from one sample | to file | `camera.rs` (`full_status_with`), `daemon.rs` | + | A short preset list is a failed read | to file | `camera.rs` (`presets`) | + | "The current pose" is the live pose | to file | `camera.rs` (`current_pose`, `save_preset`) | + | `preset save` validates the name before it sends the pose | to file | `camera.rs` (`save_preset`) | + | Test seam for the answer poll (rejected, empty, no answer, read error) | to file | `camera.rs` (`answer`) | + | Test: a rejected tracking query gives `tracking: null`, not a failed read | to file | `camera.rs` (`full_status_with`) | + | The tilt negation of `gimbal` as a tested pure function | to file | `camera.rs` (`gimbal`) | + + Tasks 1 to 5 may start before these land; each rebase of `rebuild` onto `main` (P4) carries + them through the renames. **Task 6 starts only when every fix above that touches `camera.rs` + is on `main` and `rebuild` has been rebased onto it**, so the split copies the fixed code and + nothing is ported by hand into the split files. If `main` changes `camera.rs` after Task 6 has + landed anyway (a 0.13.x patch), the rebase stops at Task 6's commit; the coordinator then + redoes Task 6 on the rebased base (Step 2 with the anchors of the committed script corrected, + G6b repeated) instead of porting the change by hand. Task 8's named mutants (Task 8, Step 5) + need the last three rows. + + Fixture rule: Task 1 lands on `main`, so a pull request on `main` that changes on purpose what + a builder sends or a help text re-records the fixture in the same pull request + (`BYTE_GUARD_BLESS=1` or `CLI_SURFACE_BLESS=1`) and lists the changed lines in its + description; the rebase carries the new fixture into `rebuild`. On `rebuild` no task + re-records a fixture. + +- **P4. Who rebases `rebuild`.** Every move of `main` (Task 1 itself, each fix of P3, any 0.13.x + patch) is followed by a rebase of `rebuild` onto `main` (spec 10.1). That replaces a shared, + protected branch, which is a force push, and nobody working on a task of this plan + force-pushes. Before `main` first moves, Thomas decides who pushes the rebased branch and the + decision goes into the tracking issue; until then `rebuild` is not rebased and Task 2 does not + start. Proposed procedure: when no pull request into `rebuild` is open, the coordinator + rebases in its own worktree, checks `git range-diff .. ..` + (only the commits of `main` are new, the `rebuild` commits differ only where a fix of `main` + shows through) and runs the gate of the Guards section on the new tip; then the decided person + runs `git push --force-with-lease=rebuild: origin :rebuild`, and the + coordinator writes old tip, new tip and the reason into the tracking issue. + +- **P5. Shellcheck findings in the old scripts.** `shellcheck` reports SC2086 in + `scripts/build-arch.sh` and SC2086 and SC2034 in `scripts/check-readback.sh` on 66287b5. The + gate of this plan checks only the scripts it adds; the coordinator files an issue on `main` + for the old ones. ## Decisions @@ -136,19 +188,33 @@ Engineering decisions for this stage, with the short reason. version comes from `CARGO_PKG_VERSION` of the same package (now inherited). The 0.13 library target `obsbot4linux` disappears: its modules are `obsbot_device` now. Nothing outside the repository links that library (checked). -4. **0.13 code that 1.0 does not carry goes to `obsbot_device::legacy`.** `state.rs` (spec 3.2: - dropped), the 0.13 status shape (`FullStatus`, `CAPABILITIES`, `LIMITS`, `StateView`, - `ZoneInfo`, `Zoom`, `zone_info`, `full_status`, `full_status_with`) and `current_pose` - (spec 3.2: not carried). The carried flows still call `State` (remembered gimbal pose and - exposure mode), so `legacy` has to live in the device crate during this stage. Stage W0 or - track A deletes `legacy` together with the 0.13 daemon and CLI; nothing in `legacy` is a - final path. +4. **0.13 code whose shape 1.0 does not carry goes to `obsbot_device::legacy`.** `state.rs` + (spec 3.2: dropped), `current_pose` (spec 3.2: not carried) and the 0.13 status + (`FullStatus`, `CAPABILITIES`, `LIMITS`, `StateView`, `ZoneInfo`, `Zoom`, `zone_info`, + `full_status`, `full_status_with`). The carried flows still call `State` (remembered gimbal + pose and exposure mode), so `legacy` has to live in the device crate during this stage. Spec + 3.1 has no such module: it exists only while the 0.13 daemon and CLI do. `legacy/full_status.rs` + is not shape alone: `zone_info` and `full_status_with` hold carried read logic (spec 3.2, + "`camera.rs` sequencing … carry over the logic"): tracking is `None` when the camera rejects + or does not answer the tracking query, audio volume and focus are not asked while the camera + sleeps, the target is not asked in static mode, and the status, zoom, pose and image reads + that make up a full read. Track A ports that logic into the Tiny 2 Lite's `trait Camera` + reads before `legacy` is deleted with the 0.13 daemon and CLI, and the file stays in the + mutation scope (Task 8). Keeping the two functions in `models/tiny2lite/camera.rs` instead + would put the 0.13 output types into the model's final module; not taken. Nothing in `legacy` + is a final path. + Module dependencies this creates: `models::tiny2lite::camera` uses `legacy::state`, and + `legacy::{state, full_status, current_pose}` use `models::tiny2lite`. Per file there is no + cycle (`legacy/state.rs` uses only `commands`); per module, `models` and `legacy` depend on + each other. Rust accepts this; W0 and track A should know it is there. 5. **File names.** `models.rs` becomes `registry.rs` and the detection half of `device.rs` becomes `enumeration.rs`, the names of spec 3.1. The device half of `device.rs` becomes `transport/uvc.rs` ("one implementation talks to USB UVC devices", spec 3.1). The exchange part of `camera.rs` (short and framed writes, answer polling, the sequence counter) becomes `family/exchange.rs`: it depends only on `transport` and `family::frame`, which is the direction of 1.0. `frame.rs` and `streaming.rs` keep their file names under `family/`. + `transport::uvc` uses `enumeration` (`Device::open` calls `detect`), so the dependency runs + from `transport` to `enumeration`; W0 should know it is there. 6. **Old module paths during the parallel wave.** Tasks 3 to 5 keep every old path valid with a re-export in `lib.rs` (`pub use family::{frame, streaming};`, an inline `pub mod device`, `pub use models::tiny2lite::{commands, presets, status};`), so none of them edits a file of @@ -166,7 +232,9 @@ Engineering decisions for this stage, with the short reason. not change, which is what the test-name guard compares. 8. **Mutation pass** (spec 10.2 and 11): its own last task, Task 8. Tool cargo-mutants 27.1.0, in place, over `obsbot-device` without `legacy/state.rs` and - `legacy/current_pose.rs` (not carried), with the whole workspace's tests. Survivors are + `legacy/current_pose.rs` (not carried), with the whole workspace's tests except the byte + guard: it records what the code emits, so it would kill builder mutants that no real test + catches, and the pass exists to find exactly those (Task 8, Step 3). Survivors are killed by new tests in that pull request (test modules only) or documented in its description; a survivor in a function without I/O must be killed or shown equivalent. Not a CI gate. @@ -230,15 +298,41 @@ Defined in Task 1, named in every task. "Base" is the commit the pull request st `cargo clippy --workspace --all-targets --locked -- -D warnings`; the CI job `msrv` (Rust 1.88) green. Locally the MSRV build needs rustup and is optional; CI is the check. - **G5 Packaging.** The release dry run of `release.yml` on the pull request is green, - including the job `contents` (the file list of all eight packages equals - `packaging/contents.txt`). It runs when the pull request touches - `.github/workflows/release.yml`, `packaging/**`, `scripts/**`, a `Cargo.toml`, `Cargo.lock`, `README.md`, - `CHANGELOG.md`, `LICENSE` or `renovate.json`: Tasks 1 and 2. The tag run of 0.14.0 runs it - again on the final tree. + including the job `contents` (the binary in each of the eight packages is stripped, and the + file list of all eight packages equals `packaging/contents.txt`). It runs when the pull + request touches `.github/workflows/release.yml`, `packaging/**`, `scripts/**`, a + `Cargo.toml`, `Cargo.lock`, `README.md`, `CHANGELOG.md`, `LICENSE` or `renovate.json`: Tasks + 1, 2, 4 and 6 (Tasks 4 and 6 add a split script under `scripts/`). The tag run of 0.14.0 runs + it again on the final tree. - **G6 Move check.** `scripts/groundwork/move-check.sh HEAD` prints only the lines the - task lists under "Expected move check". + task lists under "Expected move check". It compares lines as a multiset, so it sees a dropped, + copied or changed line but not an order change; G6b covers that for the two splits. +- **G6b Scripted split (Tasks 4 and 6).** The split is made by a committed script that copies + contiguous line ranges of the 0.13 file (`scripts/groundwork/split-device.py`, + `scripts/groundwork/split-camera.py`), so the order inside a block cannot change and the + reviewer reproduces the files it writes. `` are the files the task's script writes, + `` the `mkdir` and `git mv` lines of the task's Step 2: + + ```bash + REPO=$(git rev-parse --show-toplevel) + git worktree add --detach "$SCRATCH/g6b" + cd "$SCRATCH/g6b" + + git show :scripts/groundwork/