diff --git a/docs/design/updater-design.md b/docs/design/updater-design.md index 95856966..b60187d8 100644 --- a/docs/design/updater-design.md +++ b/docs/design/updater-design.md @@ -471,38 +471,46 @@ done only when genuinely needed, never unconditionally on every update. - CI side: `cargo-dist` can build and publish signed artifacts to GitHub Releases; we host manifests as additional assets. -### 6.1 ⚠ A private repository cannot serve the fleet +### 6.1 A private repository cannot serve the fleet — so this one does not stay private -**Unresolved, and it constrains M4.** `pollen-robotics/microduck` is private, and a -private repo's `releases/download//` URL returns **404 — with or without a -token**. Verified directly: +**Decided (2026-08-26): publish `pollen-robotics/microduck`.** While it is private a robot in +the field cannot download anything, and the reason is worth keeping because it is not obvious: +a private repo's `releases/download//` URL returns **404 with or without a token**. +Verified directly: | URL | private repo | |---|---| | `https://github.com//releases/download//` | 404, authenticated or not | | `https://api.github.com/repos//releases/assets/` + `Accept: application/octet-stream` | 200 with a token | -The engine now resolves every asset through the API endpoint, so a **developer's board** -works: `GITHUB_TOKEN` is in the environment and `--ref` installs a branch build. Verified end -to end against this repo. +So the engine resolves every asset through the API endpoint, which works for a **developer's +board** — `GITHUB_TOKEN` is in the environment and `--ref` installs a branch build — and for a +public repo, but not for a customer robot, which has no token and should not have one: a +fleet-wide credential baked into an image leaks and cannot be rotated without reflashing, the +same problem the signing keys are tiered to avoid. -**A customer robot has no token, and should not.** A fleet-wide credential baked into an -image is a credential that leaks and cannot be rotated without reflashing — the same problem -the signing keys are tiered to avoid. So as things stand, robots in the field cannot download -anything. The options, none of them chosen yet: +Three other options were on the table, and are recorded because the decision could be revisited +if the source ever needs to close again: | option | keeps zero-backend | notes | |---|---|---| -| A **public repo holding only release artifacts** | yes | signatures are what make an artifact safe, not obscurity — a public artifact repo leaks build metadata and nothing else. Source stays private. | -| Make this repo public | yes | product source; presumably not. | -| An object store or CDN with a plain HTTP source | mostly | one more thing to own and pay for; the engine's source trait already abstracts it. | -| A read-only token in the image | yes | rejected reasoning above: an unrotatable fleet credential. | - -The first is the conventional answer and costs nothing but a second repository. It does not -change the engine — only `repo` in `updater.toml` and where `release.yml` publishes. - -Nothing about this blocks M2 or M3: dev boards have tokens and sim needs no downloads. It -blocks the first robot that has to update itself without a developer present, which is M4. +| A **public repo holding only release artifacts** | yes | signatures are what make an artifact safe, not obscurity — an artifact repo leaks build metadata and nothing else. Source stays private. The fallback if this repo ever goes private again. | +| An object store or CDN with a plain HTTP source | mostly | one more thing to own and pay for; the source trait already abstracts it. | +| A read-only token in the image | yes | rejected: an unrotatable fleet credential. | + +**Going public changes no code.** The API path stays correct — it is the one path for private +and public alike, which is why it was written that way. + +**What it does change is a budget.** Unauthenticated GitHub API requests are limited to 60 per +hour *per IP*, and a token lifts that; a robot in someone's home has no token, so its checks +spend from the anonymous pool shared by everything behind that address. At `check_interval = +"6h"` and a handful of calls per check, one duck is nowhere near it — a room of twenty on the +same wifi, updating together, can be. It is not a correctness problem: `http.rs` already reads +403 and 429 as "come back later" and says so in the message. It is a reason to prefer +`browser_download_url` for the bytes once the repo is public, since object downloads from +`objects.githubusercontent.com` spend nothing from that pool, and to keep the API path for +private repos and dev boards. Worth doing before a room full of ducks exists, not before the +first one ships. ## 7. `updaterd` state machine diff --git a/docs/project/roadmap.md b/docs/project/roadmap.md index 35cec3ba..1186c55d 100644 --- a/docs/project/roadmap.md +++ b/docs/project/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -Status: draft · Date: 2026-08-05, revised 2026-08-26 · Owner: pierre +Status: rewritten · Date: 2026-08-26 (first written 2026-08-05) · Owner: pierre Companion to [`architecture.md`](../design/architecture.md) (what we're building) and [`updater-design.md`](../design/updater-design.md) (how it ships). This is *order and sequencing* @@ -11,301 +11,374 @@ Companion to [`architecture.md`](../design/architecture.md) (what we're building | | | |---|---| | `updater/` | engine, verification, store, journal, hooks, preflight, GitHub/HF/local sources, IPC server, systemd unit — **done** | -| `duck-control/` | robot model · bus · IMU · `RobotIo` · observations · ONNX policy · safety — **slices 1–2 done, and run on a robot**. A library: no tokio, no sockets, no systemd | -| `duck-ipc-proto/` | wire contract for `update.*` and `robot.*` — **done**; serde/serde_json/semver only, so nothing on the recovery path pulls the engine's tree | -| `robotd/` | a real 50 Hz loop driving walk/stand through the safety layer, intents, health from deadline adherence and policy state — **slices 1–2 done, and it walks on a board**. Since then: kinematics, contact odometry, the voice, the ToF theremin and the chorale, all hung off the same tick ([`robotd-design.md`](../design/robotd-design.md) §4.4–4.5) | -| `padd/` | gamepad → intents, as an ordinary socket client — **done**, ships in the release and runs as its own unit from boot, so pairing a pad is the only step; needs libudev, installed by CI and the board cross-build | +| `duck-control/` | robot model · bus · IMU · `RobotIo` · observations · ONNX policy · safety. A library: no tokio, no sockets, no systemd | +| `duck-ipc-proto/` | wire contract for every `*.` namespace, at API v14 — serde/serde_json/semver only, so nothing on the recovery path pulls the engine's tree | +| `robotd/` | a 50 Hz loop driving walk/stand/roll through the safety layer, intents, health from deadline adherence and policy state. Since M3: kinematics, contact odometry, gaze IK, the voice, the ToF theremin and the chorale, all hung off the same tick ([`robotd-design.md`](../design/robotd-design.md) §4.4–4.5) | +| `padd/` | gamepad → intents, as an ordinary socket client; ships in the release and runs as its own unit from boot, so pairing a pad is the only step | | `robotctl/` | the operator CLI — `update`, `health`, `version`, `monitor`, `net`, `system`, `robot`, `pad`, `configure`, `quack`, `chorale`, `theremin`, `completions`; depends on `duck-ipc-proto`, not `updater`, so it stays on the recovery path | -| `xtask/` | package · sign · promote — **done**, byte-identical promotion verified | -| `.github/` | ci · release · promote — **all three run for real**: `0.2.0` was tagged to staging, verified through the engine, installed on a board and promoted to stable on 2026-08-05, byte-identical (§16.3) | +| `configd/` | wifi over NetworkManager, robot name and the identity derived from the SoC serial, pairing PIN, reboot, unit reporting. `--fake-net` serves the whole surface off-board | +| `btd/` | BLE transport adapter — framing, the routed subset, the BlueZ backend, a pairing agent. Works on hardware, unencrypted by default — [`app-path-design.md`](../design/app-path-design.md) §5.5 | +| `duckctl/` | the robot from a laptop. BLE today; named for the robot rather than the radio | +| `mediad/` | camera, mic, encode and the WebRTC gateway, plus the console it serves. **Streaming to a browser on the LAN from a Radxa Zero 3W**, hardware H.264 through `mpph264enc`, `control` datachannel alongside | +| `tof/` | `tofd`: the head's 8×8 ToF matrix on its own socket at 15 Hz. A board with no sensor fitted runs it anyway and says so | +| `xtask/` | package · sign · promote — byte-identical promotion verified | +| `.github/` | ci · release · promote · dev — all four run for real; every release since `0.2.0` reached a board through them | | bootstrap | `updaterd install` + `scripts/install.sh` — a robot installs its first release through the **ordinary engine**, so there is no bootstrap-only code path to drift | -| `deploy/` | shipped `updater.toml`, `robotd.toml`, trust anchor, journald retention drop-in | -| `scripts/` | `install.sh` provisioning · `board-test.sh` — **passing in CI**: 13 checks on emulated aarch64, Debian 13 (Trixie) | -| `btd/` | BLE transport adapter — framing, the routed subset, the BlueZ backend, a pairing agent. **Works on hardware**, unencrypted by default — the blocker, [`app-path-design.md`](../design/app-path-design.md) §5.5 | -| `duckctl/` | The robot from a laptop. BLE today; named for the robot rather than the radio, because `mediad` is a second transport | -| `configd/` | wifi over NetworkManager, robot name and the identity it derives from the SoC serial, pairing PIN, reboot. **Drives a real NetworkManager on a board**: provisioned over BLE, joined, and rejoined by itself after a reboot. `--fake-net` still serves the whole surface off-board | -| `mediad/` | camera, mic, encode and the WebRTC gateway, plus the console it serves. **Streaming to a browser on the LAN from a Radxa Zero 3W**, hardware H.264 through `mpph264enc`, with a `control` datachannel alongside — [`remote-webrtc.md`](../design/remote-webrtc.md) §0. Ships in the release and runs as its own unit | -| `tof/` | `tofd`: the head's 8×8 ToF matrix, published on its own socket at 15 Hz. Read by `robotd`'s theremin and drawn by `robotctl monitor`; a board with no sensor fitted runs it anyway and says so | -| tests | **936 passing** on a Mac (`--exclude tof`), a few more on Linux — including the health gate, the battery+thermal readout and the policy/safety path against a real `robotd` process, and `configd`'s authorisation over real sockets in `board-test.sh` | -| missing | the app, the SDK, and reaching a robot from outside the LAN | -| on hardware | walking through the intent API, the update path (install · health gate · commit · auto-rollback), a signed release installed from the stable channel, BLE provisioning of wifi. The loop held 50.0 Hz with `missed=3` in 15022 ticks before inference | -| not on hardware | the numbers M4 exists for: thermals, eMMC write timing, battery under load, and whether logs survive a power cut. The 30s health-gate timeout is still a guess | - -## The framing - -**The hard part is productisation, not capability.** `microduck_runtime` already walks, -runs gait policies, does perception and mapping. What doesn't exist is a robot you can -hand to a stranger: app-driven updates, safety authority, privacy, provisioning, -recovery. Porting existing capability into the new architecture is laborious but -*known-feasible*; the unknowns are all on the productisation side. - -~~**The updater is finished and instrumentally useless.**~~ It had nothing real to ship, which -was the whole argument for the ordering below. That is now over: `0.2.0` ships a robot that -walks, and the update path is how it got onto a board. - -## What changed the order: the team arrives (written 2026-07-28) - -Others will work on `robotd`/`mediad` and **share builds through the updater**. That -makes two things urgent that would otherwise have waited: - -1. **Dev-channel installs** — install a specific branch or commit on a board, without - cutting a release. This is now ahead of `btd`: teammates will use `robotctl`, not the - phone app. -2. ~~**A repo and a dev signing key**~~ — **done.** `pollen-robotics/microduck` - (private), CI green on first fix; `team.dev` key generated. The signing secrets and the - `release` environment are in place too, and `0.2.0` went out through them. - -~~`btd` and the app path slip behind both.~~ Both landed early anyway — see M6 for why the -trigger turned out not to be the phone app. +| recovery | `robot-boot-check.timer` + `robot-rescue` + the `golden` symlink ship and are enabled. **Never exercised on a board** ([`boot-recovery-net.md`](../design/boot-recovery-net.md)) | +| tests | **942 passing** on a Mac with nothing excluded, a few more on Linux — including the health gate, the battery and thermal readout and the policy/safety path against a real `robotd` process, and `configd`'s authorisation over real sockets in `board-test.sh` | +| in flight | `maploc` (#127), the NPU duck detector, the chorale election fix (#151); two design PRs with nothing built — the phone app (#107) and the IPC monitor (#52) | +| missing | the app, the SDK, the model channel, the autonomous brain, and reaching a robot from outside the LAN | + +## The first roadmap reached its target + +M1 to M4 were one sequence with one destination: a robot that walks, updates itself over the +air, and cannot be bricked doing it. That arrived. Every release since `0.2.0` reached a board +through the machinery M1 and M2 built, and `0.9.x` is a duck that walks, rolls, sees, talks and +sings. + +What follows is not a continuation of that queue. The work left is independent tracks with +different risks and no ordering between them, so **the numbers below are identifiers, not a +sequence** — [the order of work](#the-order-of-work) is its own section. + +**The founding claim was half right.** *The hard part is productisation, not capability* judged +the difficulty correctly: the update path, the health gate, the recovery net and the BLE surface +all got built, and all hold. It judged the volume wrong. Nearly everything since `0.5` has been +capability — kinematics, odometry, ToF, gaze, the voice, the chorale, the camera — and the +largest single piece of it, the autonomous brain, is still unported. ## Milestones Each has a test that says "done", because milestones without one drift. -### M1 — Close the loop · **done** - -The updater got something real to gate against, and the team got a shared crate boundary. - -- **`robotd` skeleton** — heartbeat plus the four `robot.*` methods `updaterd` calls. Its - state is atomics, not a mutex: a robot whose control loop is wedged must still be able to - answer "I am not healthy", and needing the loop's lock to answer would hang in exactly the - case that matters. `--unhealthy` / `--busy` exercise rollback on a bench robot. -- **`duck-ipc-proto` extracted** — `robotd` and `robotctl` depend on it and not on `updater`, - so nothing on the recovery path links the engine's http/tar/crypto tree. -- **The health gate is real** — `on_apply` restarts `robotd`, `health` is a socket probe, and - a test fails if either regresses to its inert bootstrap value. -- **One source of truth for the robotd socket** — `robot_socket` at the top level of the - config; `--robot-socket` is a documented dev override. -- **Logging and version reporting** — every daemon's first line is its own identity (version, - revision, exe path) at `warn`, so it survives `RUST_LOG=warn`; `robotctl version` reports - running *and* installed per service, because `updaterd` never restarts itself and so - legitimately lags until reboot. -- **First-install bootstrap** — `updaterd install` + `scripts/install.sh`, through the - ordinary engine. - -**Done:** `robotd/tests/updater_gate.rs` gates an update against a real `robotd` process over -a real socket and commits; `robotd --unhealthy` reverts the content behind `current`. - -`robot-config` was dropped from this milestone: M1's test is about the health gate, and a -heartbeat daemon needs CLI flags, not a shared config store. It lands when something reads it. - -### M2 — Dev channel · **done** - -Install a branch on a board without cutting a release: - -``` -sudo robotctl update apply daemon --ref my-branch -``` - -- **`Target::Ref`** and `manifest_at_ref` on the source trait. `--ref` conflicts with - `--version` rather than one silently winning. -- **`dev.yml`** — every branch push publishes `-dev..` to the moving tag - `daemon-dev-`, signed with `team.dev`. -- **`xtask package` accepts a prerelease of the crate version** without - `--allow-version-drift`, so the escape hatch stays reserved for what it was built for. -- **Refs work on `local_dir`** too, which makes the path testable offline and is the sideload - story. A ref becomes a filename there, so separators and `..` are refused. - -Two properties make this safe on every push, both enforced away from the workflow: - -- A dev build **cannot become `latest`** — the version is a semver prerelease, and - `version_under` refuses to read a dev tag as a release version. -- A dev build **cannot install on a customer robot** — `allow_dev_keys` is false there, and a - trusted key only counts as a dev key if its filename ends `.dev.pub`. - -A ref bypasses the downgrade guard by design: a prerelease always sorts below the release a -board is on, so guarding it would refuse every branch install. A plain `apply` returns the -board to the release stream, since `latest` resolves to the highest *stable* version. - -- **`apply --from `** and `scripts/dev-push.sh` — build on a laptop, install on a board, no - push and no CI run. A per-call source override rather than a config edit, so the board keeps - reaching GitHub for `--ref`, `--staging` and a return to the release stream. It is an ordinary - apply: health gate, auto-rollback, dev-key verification. `API_VERSION` moved with it, because a - daemon one version older would have parsed the option, ignored it, and installed from its - configured source while reporting success. v7 first ships in **0.5.0**, so each board takes one - `scripts/dev-push.sh --bootstrap` to get there — the binary that would gate the update is the one - being replaced. +### M1 — Close the loop · **done** + +The updater got something real to gate against, and the team got a shared crate boundary: +a `robotd` skeleton whose state is atomics rather than a mutex (a robot whose loop is wedged +must still be able to answer *I am not healthy*), `duck-ipc-proto` extracted so nothing on the +recovery path links the engine's http/tar/crypto tree, a health gate that is a real socket +probe, one source of truth for the `robotd` socket, an identity line every daemon logs at +`warn` before anything can fail, and first-install bootstrap through the ordinary engine. + +**Done:** `robotd/tests/updater_gate.rs` gates an update against a real `robotd` process over a +real socket and commits; `robotd --unhealthy` reverts the content behind `current`. + +### M2 — Dev channel · **done** + +`sudo robotctl update apply daemon --ref my-branch` installs a branch build, and +`scripts/dev-push.sh` installs a laptop build over ssh with no CI run at all. Two properties +make that safe on every push, both enforced away from the workflow: a dev build **cannot become +`latest`** (the version is a semver prerelease and `version_under` refuses to read a dev tag as +a release version), and it **cannot install on a customer robot** (`allow_dev_keys` is false +there, and a trusted key only counts as a dev key if its filename ends `.dev.pub`). **Done:** verified against the real repository — `dev.yml` published, `--ref main` installed over the network, and a customer-robot config refused the same build. -**Open, and it blocks M4:** a private repo's release assets need a token, and a customer robot -has none. See `updater-design.md` §6.1. - -### M3 — `robotd` for real · **done**, in two slices - -Designed in [`robotd-design.md`](../design/robotd-design.md). `robotd` **replaces** -`microduck_runtime`, by extracting its control core into `duck-control` rather than -reimplementing it — so the prototype keeps running while the daemon grows, and parity -arrives as a consequence of the extraction instead of as a race against a moving target. -Only the alpha variant on the Radxa survives; the other three variants, four IMUs and two -boards are dropped. - -**Hardware first, sim after.** An earlier draft of this milestone said the reverse. It was -wrong on the facts: there are boards, and correctness gets settled on them. The simulator's -job is a clean laptop dev environment, not a validation oracle, so it lands after slice 2 -and never becomes a second definition of what the robot is. Tests run against a `FakeIo` -backend — no hardware, no network, no Docker, no Python. - -**Slice 1 — hold the pose · done, on a board.** A real 50 Hz loop on the Dynamixel -bus, holding whatever pose it starts in. No policy. It exists so `robot.health` means *the -loop is meeting its deadline* rather than *it ticked once* — until now the updater's -auto-rollback has been gating on a placeholder. Holding a pose is also what makes it safe to -hammer install/rollback/power-cut cycles at a bench for a day. - -**Slice 2 — walk and stand.** One 61-D observation builder (every alpha policy is -`obs[1,61] → actions[1,14]`), the main-plus-standing policy shaped as it is in the runtime, -`move`/`head`/`stop`/`enable` intents, and a gamepad client that goes through them. - -**Safety authority belongs here, not in M6** — `architecture.md` §6 designs it and nothing -implements it. It lands in slice 2, holding the only write handle to the bus, so no policy -and no client *can* command a motor around it. Joint clamp, fall → limp, and an intent -deadman; thermal waits for a measured threshold rather than a guessed one. - -**Done:** all three, on a Radxa Zero 3W. It walks driven through the intent API; an update -applied with `robotctl` restarts it cleanly with the gate passing; and a release that comes up -unhealthy reverts on its own. The board also produced the one bug the tests could not: `ort` -*panics* rather than erroring on a runtime below its floor, which killed the control thread and -made health blame the wrong thing. It now holds the pose and names the version instead. +**One thing it left open, now decided:** a private repo's release assets are reachable with a +token and a customer robot has none, so while this repository is private robots in the field +cannot download anything. **The decision is to publish it**, which resolves that and changes no +code — the API path the engine uses works for public repos too +([`updater-design.md`](../design/updater-design.md) §6.1). + +### M3 — `robotd` for real · **done**, in two slices -### M4 — Hardware bring-up · in progress +`robotd` **replaced** `microduck_runtime` by extracting its control core into `duck-control` +rather than reimplementing it, so parity arrived as a consequence of the extraction instead of +as a race against a moving target. Slice 1 was a real 50 Hz Dynamixel loop holding its pose — +which is what makes `robot.health` mean *the loop is meeting its deadline*. Slice 2 was one +61-D observation builder, the main-plus-standing policy, the intent surface, and a gamepad +client going through it. + +**Safety authority landed here rather than in M6**, holding the only write handle to the bus, +so no policy and no client *can* command a motor around it: joint clamp, fall → limp, and an +intent deadman. + +**Hardware first, sim after** — and the board produced the one bug the tests could not: `ort` +*panics* rather than erroring on a runtime below its floor, which killed the control thread and +made health blame the wrong thing. -M3 on the Radxa with real motors and IMU. This is where the genuinely unknown numbers -appear: control-loop jitter on a non-RT kernel, ONNX inference rate on Cortex-A55, eMMC -write timing, thermals, battery. Also the first real test of `systemctl restart` in -`on_apply`, and of the health-gate timeouts — 30s is currently a guess. +**Done:** all three on a Radxa Zero 3W. It walks driven through the intent API; an update +applied with `robotctl` restarts it cleanly with the gate passing; a release that comes up +unhealthy reverts on its own. -**Settled already**, because slices 1 and 2 could not be finished without them: the loop holds -its rate on a non-RT kernel (50.0 Hz, `missed=3` in 15022 ticks, before inference), the bus and -the `imu_to_dxl` board answer on `/dev/ttyS2`, `systemctl restart` in `on_apply` works against -real systemd, and the gate commits and reverts for real. **Still open, and they are the reason -this milestone is not closed:** thermals, eMMC write timing, battery under load, whether the 30s -timeout has any margin on a cold boot, and the log-retention question below. +### M4 — Hardware bring-up · **closing** -Also the first chance to settle the **log retention** question, which cannot be answered -off-board (`deploy/README.md`): +A measurement milestone, not a feature one: it exists to turn guesses into numbers on a real +Radxa. Nearly all of it was answered as a side effect of shipping — the loop holds 50.0 Hz on a +non-RT kernel (`missed=3` in 15022 ticks), the bus and the `imu_to_dxl` board answer on +`/dev/ttyS2`, thermals have a real reading across every zone, `systemctl restart` in `on_apply` +works against real systemd, and the gate commits and reverts for real. -- `findmnt /var/log` — if Armbian's RAM-log has it on tmpfs, journald's `Storage=persistent` - is a directory in memory: it survives a clean `reboot` and loses recent logs on a power - cut, which is how a robot is actually switched off. Decide explicitly: disable the RAM log - and accept eMMC writes, or keep it and rely on the update history (which does not go - through `/var/log`). -- `journalctl --list-boots` after a real reboot — two or more entries, or the drop-in is not - doing what it claims. +**The log-retention question was settled by deciding rather than measuring.** `/var/log` is a +zram device on this image, so `Storage=persistent` gets journald a directory that is itself in +memory: it survives a clean `reboot` and loses recent logs on a power cut. That is the intended +arrangement — the durable record is the update history under `/var/lib`, `fsync`ed per entry +([`deploy/README.md`](../../deploy/README.md)). So the original "logs survive a power cut" +criterion is retired: it asks for a property this project decided not to have. -**Done when:** it walks on hardware, an update applied via `robotctl` restarts `robotd` -cleanly with the gate passing, and `journalctl -u robotd -b -1` returns the previous boot's -logs after a power cut. +**Done when:** it walks on hardware, an update applied via `robotctl` restarts `robotd` cleanly +with the gate passing, and `journalctl --list-boots` reports the previous boot after a reboot. +The first two are done; the last is one command on a board and is the only thing outstanding. -### M5 — `mediad`, WebRTC, SDK · in progress +**Two numbers deliberately left unmeasured**, because nothing is waiting on them: eMMC write +timing and battery under load. They belong to M7, which is the milestone about knowing what a +board is doing. -Camera/mic, encode, perception, the remote gateway. Privacy lands here and not later: -per-session consent and a visible streaming indicator are cheap now and expensive to -bolt on. The SDK's WebSocket + snapshot path (§5.3) is what makes "an LLM drives the -robot" easy. +### M5 — `mediad`, WebRTC, SDK · **in progress** **Landed, on hardware.** `mediad` ships in the release and runs as its own unit. The camera -reaches a browser on the LAN through the VPU — `mpph264enc` → `webrtcsink`, constrained baseline — -with a `control` datachannel carrying the same JSON-RPC every other transport speaks, and the -console is served by the robot itself so there is a URL and nothing to install -([`remote-webrtc.md`](../design/remote-webrtc.md) §0, [`webrtc-console.md`](../design/webrtc-console.md)). -Two GStreamer plugins had to be built from source to get there, which is its own record -([`media-bringup.md`](media-bringup.md)), and the camera has since got 3A through `rkaiq`. - -**Still open, and they are what keeps this milestone from closing:** reaching a robot from outside -the LAN — the design is the same one with a proxy in front (§7), deliberately built second — the -SDK, and the privacy pair. Consent and the streaming indicator are *not* done, and this is the -milestone that was supposed to stop them being bolted on, and the reason given for deferring them -is a hardware one — an LED under software control, which does not yet exist -([`remote-webrtc.md`](../design/remote-webrtc.md) §11). - -**Done when:** telepresence works from outside the LAN, and a server-side script can -fetch a frame and send an intent in a few dozen lines. +reaches a browser on the LAN through the VPU — `mpph264enc` → `webrtcsink`, constrained +baseline — with a `control` datachannel carrying the same JSON-RPC every other transport +speaks, and the console is served by the robot itself so there is a URL and nothing to install +([`remote-webrtc.md`](../design/remote-webrtc.md) §0, +[`webrtc-console.md`](../design/webrtc-console.md)). Two GStreamer plugins had to be built from +source to get there ([`media-bringup.md`](media-bringup.md)), and the camera has since got 3A +through `rkaiq`. + +**Three things keep it open.** + +**Outside the LAN.** The same design with a rendezvous service and TURN in front (§7), +deliberately built second. + +**The SDK, and a small Python client.** §5.3 designs it as WebSocket plus snapshot: the same +JSON-RPC, no media stack, `get_frame` returning a JPEG, a few dozen lines — and `mediad`'s +session layer was built so that surface reuses it unchanged (`mediad/src/session.rs`). A Python +client over **WebRTC** instead gets live video and the `control` datachannel from one +connection, at the cost of `aiortc`, an ICE negotiation and a signalling round trip for a caller +who only wants to send an intent and read a frame. **The investigation is whether one client +covers both** — WebSocket for control and snapshots, WebRTC only when the caller asks for a +stream — or whether the WebSocket surface alone is what a script wants and live video stays in +the console. Answer that before writing either, because it decides whether the SDK is fifty +lines or a project. + +**Privacy, and it is now two items rather than one.** *Consent* — explicit per-session approval +before a stream starts — is a `mediad` session-layer change and is not blocked on anything. The +*visible indicator* needs an LED under software control, which does not exist on this hardware +([`remote-webrtc.md`](../design/remote-webrtc.md) §11); it is a hardware question and it should +be asked of the hardware rather than parked on a software milestone. `architecture.md` §7 is +right that both are cheap now and expensive later, so consent should not wait for the LED. + +**Done when:** telepresence works from outside the LAN, and a server-side script can fetch a +frame and send an intent in a few dozen lines. ### M6 — Ship readiness -`btd` + the app update path, provisioning (device identity, calibration, key -installation), recovery mode (§8.2's last link), manifest staleness reporting (§8.4.2), -and the authority arbitration finished. - -**`btd` and `configd` landed early**, out of this order. The trigger was wanting to configure a -robot — wifi, name, reboot — from something other than an SSH session, and the work turned out to -be mostly *not* Bluetooth: an API surface and a service to own it, which the phone app, the SDK, -`robotctl` and `mediad`'s gateway all need identically. `btd` is a thin pipe over it. - -What that leaves for M6 proper: recovery mode, manifest staleness, authority arbitration, and the -provisioning step the pairing PIN now depends on (below). It also means the app has something to -talk to before the app exists, which is the right order for finding out that an API is wrong. - -**The update path over Bluetooth is now driven rather than merely routed**, which is what that -order was for: `update-over-ble.md` records what driving it from `duckctl` turned up, including -one defect that made "start an update and watch it" an update the robot silently never performed. -`update.rollback` and `update.select` are reachable from a phone as of that work. - -**Done when:** a non-developer updates the robot from the phone, and a deliberately -bricked release recovers without a laptop. - -## Organisation - -**One repo, one workspace.** `robotd`, `btd`, `configd`, `padd`, `mediad` and `tofd` are all -siblings now — nothing is outstanding. They co-version because they all ship in the same -`daemon` artifact — one version line is correct, and models version separately already. - -**Crate layout as it should end up:** - -It ended up wider than this section first drew it — the libraries `robotd` drives are their own -crates now, for the reason `duck-control` was: the compiler is what keeps daemon concerns out of -them. The list lives in [CONTRIBUTING.md](../../CONTRIBUTING.md#the-layout) rather than here, -because it is reference and this page is a record. The shape: - -``` -duck-ipc-proto/ wire types — serde/serde_json/semver only; btd/robotd/robotctl depend - on this, never on updater -updater/ engine + updaterd -robotd/ control, gait, safety, the voice, the theremin, the chorale -duck-control/ the control core, extracted from the prototype runtime -kinematics/ odometry/ sounds/ pet-detect/ robotd-params/ - libraries robotd drives — no sockets, nothing systemd starts -configd/ wifi (NetworkManager), robot name, pairing PIN, reboot, gamepad pairing (BlueZ) -padd/ gamepad → intents; a client, with no privilege the app will not have -mediad/ camera, encode, WebRTC gateway, and the console it serves -tof/ tofd — the head's depth sensor, published on its own socket -btd/ BLE transport adapter -robotctl/ CLI -duckctl/ the client, from a laptop (dev tool, never shipped) -xtask/ build/publish tooling — never ships -``` - -**Docs per concern, not per service.** `architecture.md` is the cross-cutting contract; -a service gets its own design doc only when it earns one (`updater-design.md` is the -model). Resist one giant document. - -**Two channels of work for newcomers.** A teammate on `robotd` should be able to: clone, -`cargo test`, run against sim, push a branch, and install it on a board via `--ref`. -That's the whole onboarding path, and M1+M2 are exactly what make it true. +What a stranger needs. Preorders are open, so this is ordered by **lead time** rather than by +difficulty — the items with the least code have the longest lead. + +- **The pairing PIN.** BLE pairing security rests on a per-robot PIN; the factory default is + `000000` and public in this repository, so out of the box pairing proves physical presence and + nothing more. Something has to generate one, print it, and record what was printed — a factory + process, not a patch. It cannot come from the identity, which is published in an + advertisement. +- **Calibration**, the other half of provisioning. Identity no longer waits on it: a robot + derives one from its own SoC serial and names itself `duck-c51b`. +- **The recovery net, exercised on a board.** It is built and shipped and has never run against + a release whose daemons cannot start, which is the only path that matters. Cheapest item here: + one board, one deliberately broken release. +- **Consent**, from M5. +- **Manifest staleness reporting** (§8.4.2). +- **Authority arbitration**, finished — including the edge #52 surfaced: `Call::is_mutating()` + does not cover `robot.enable`, so the call that starts a policy running on a walking robot is + classified alongside `hello`. +- **The app.** #107 designs it and builds nothing. The blocker is a phone spike — scan, connect, + `hello`, authenticate, `system.info` with `--require-pairing` on, on a real iPhone and a real + Android — because §5.5 is currently a fact about CoreBluetooth on a laptop. + +One item left M6 by being decided: where a customer robot downloads from. The decision is to +publish this repository, so a shipped robot reaches its releases with no token and no second +host. The follow-up it leaves is a budget rather than a blocker — anonymous GitHub API requests +are capped at 60 an hour per IP, which one duck is nowhere near and a room of twenty on one wifi +is not (§6.1). + +**Done when:** a non-developer updates the robot from the phone, and a deliberately bricked +release recovers without a laptop. + +### M7 — Knowing what a board is doing + +A duck in someone else's hands will develop a fault, and today the answer to *which part* is a +developer reading a journal over ssh. There is no shape for this yet; it is an open +investigation, and the questions are what it owns. + +**What exists already** is more than it looks: `robotctl health` reports the bus, the IMU, the +battery, per-motor temperatures and the SoC; `robotctl monitor` draws the loop, the pad stream, +the ToF matrix, the 3D robot and a power row; `scripts/pad-stack-report.sh` is a precedent for +collecting one subsystem's whole story into something a person can paste; `board-test.sh` runs +60 checks, but on emulated aarch64 in CI rather than on the board in front of you. + +**What is missing, as questions:** + +- **A support bundle.** One command, after a fault, producing a file someone can send. Nothing + collects health, versions, the journal, the update history and the unit states together. +- **A hardware pass/fail a non-developer can run.** Bus scan and a per-servo answer, IMU sanity, + ToF present, camera present, NPU present, mic and speaker, battery under load. Closer to + `board-test.sh` in spirit, aimed at the hardware rather than the release. +- **History, because intermittent faults are invisible in a snapshot.** Bus read failures, + missed ticks, servo temperature peaks, brownouts. The journal does not survive a power cut by + design (M4), so anything that matters here needs the update history's durability rather than + the journal's. +- **The overlap with #52.** The IPC monitor design is the software half of this same question, + and its crash record — the last events flushed to a fixed-size file under `/var/lib`, outside + release dirs so it survives update and rollback — is exactly the durability property a + hardware fault report needs. The two should be decided together rather than growing two + answers. + +M4's two unmeasured numbers land here: eMMC write timing and battery under load. + +**Done when:** someone who is not a developer can run one command after a fault and produce +something that names the part at fault, or says the hardware is fine. + +### M8 — The model channel: policies from the Hub + +Today every policy ships **inside the daemon artifact** — `robotd` loads +`.../current/policies/alpha_walking.onnx` and friends — so a new gait needs a daemon release, +and a policy trained on a laptop reaches a duck only through CI or a sideload of the whole +daemon. The point of this milestone is that a policy trained in `microduck_rl` — the training +repository, which is private — can be published, installed, tried and rolled back on its own +version line. + +**The engine was designed for that arrangement and most of it is already built:** + +- the `hf_hub` source resolves `https://huggingface.co/{repo}/resolve/{revision}/{file}` and + verifies **our own** minisign signature, because HF signs nothing for us (§5.1); +- a model is an ordinary component — its own version line, install dir, rollback target, pin, + boot-counter trial and known-bad history. `robotctl update apply model-walk` and + `robotctl update select model-walk 1.1.0` work the moment one is configured (§5.5); +- `on_apply = { action = "reload", unit = "robotd", signal = "SIGHUP" }` is implemented in the + engine, so a weights swap does not have to restart motor control; +- `xtask sign` already signs any directory of artifacts. + +**What is missing is at the two ends, not in the middle:** + +- **`robotd` cannot reload.** There is no SIGHUP handler and no way to swap an `ort` session + under a running 50 Hz loop. This is the milestone's real engineering: the swap must not drop a + tick, and a model whose shape is not `obs[1,61] → actions[1,14]` has to be refused *before* it + goes live rather than at the first inference. +- **Nothing publishes a bundle.** `xtask package --channel model-walk` is close — it checks + `--version` against the crate version, which a model does not have — and the HF repo layout + and naming do not exist. +- **A third signing key.** `release-1` is CI's and `team.dev` installs nothing on a customer + robot, so *who may publish a policy a robot will run* is a new custody question, not a reuse + of an existing one. +- **`model_api`** (§5.5) is designed and unimplemented on both sides. +- **The training loop.** `microduck_rl` trains and exports to ONNX; nothing carries the result + to a board without a daemon release. The model equivalent of `dev-push.sh` is what makes + "train it and try it" a minute rather than a CI run. + +**Looking at what other people have made** is the other half of the ask, and it lands on the +trust model rather than on the plumbing. Our own policies are basic and we sign them; a +stranger's is signed by nobody this robot trusts, and every artifact the engine installs is +verified against a trusted key. Three things to settle with the milestone rather than after it: + +- **Curated or open.** A model published into an org we sign for keeps every guarantee the + component system already gives — rollback, pin, known-bad, the health gate — and costs + nothing new. An open set needs an explicitly unverified install path: off by default, never + auto-applied, and refused on a customer robot the way `allow_dev_keys` already refuses dev + builds. +- **The shape gate stops being a nicety.** `obs[1,61] → actions[1,14]` has to be checked before + a model goes live whoever signed it, because an arbitrary policy drives fifteen servos. +- **What makes it survivable is already built.** The safety layer holds the only write handle to + the bus — joint clamps, fall → limp, an intent deadman — so a bad policy is bounded rather + than dangerous. That is the argument for allowing a stranger's model at all. + +**Slots stay fixed, sources do not.** `walk`, `stand`, `kick_left` and the rest are components +with their own version lines; "look for others" is a query over the Hub for models tagged for +this robot, plus repointing one slot's source at another repo. Letting arbitrary components +appear at runtime would mean the config is no longer the authority on what a robot may run, +which is the property the whole component design rests on. + +**One decision comes before all of it, and the lean is that policies leave the artifact.** Two +things follow, and neither is a reason to reverse it: + +- A freshly flashed board with no network has no gait. +- The sharper one: `robotd` reports **unhealthy** when a policy it wanted could not be loaded + (`deploy/robotd.toml`, `[policy] enabled`), so on a board with no models installed every + subsequent daemon update would fail its health gate and roll back — an update loop caused by + a missing file the update could not have supplied. + +Both have answers that already exist: + +- **A missing model is `degraded`, not unhealthy.** `HealthResult::degraded` was built for + exactly this shape — a condition that is a property of the *board* rather than of the release + being gated, where reverting the daemon cannot fix it and only churns the boot counter. Which + model components are installed is precisely that, so the gate commits and `robotctl health` + says which policy is missing. +- **Provisioning installs the bootstrap set**, the way `setup-board.sh` already installs the + ONNX runtime and `setup-gstreamer.sh` the plugins. The network dependency lands where one + already exists, and at runtime there is exactly one source for a policy — the component's + install dir — with no precedence rule between a release copy and a Hub copy. + +The alternative — the release keeps its copies as a floor a Hub component overrides — buys a +duck that walks with no network at all, at the cost of two sources for one file and a rule about +which wins. It stays on the table if bootstrapping at provisioning turns out to be fragile. + +This milestone does *not* inherit M6's download problem: the Hub is public, whatever the source +repo does. + +**Done when:** a policy trained in `microduck_rl` is published to the Hub, installed on a duck +with `robotctl update apply model-walk`, and rolled back with `robotctl update select` — with +the control loop never dropping a tick through either — and someone who did not train it can +find it from the robot and try it. + +### M9 — The autonomous brain + +The biggest untracked gap: the runtime's `autonomous.rs` exists nowhere in the daemon and no +design doc owns it. [`ideas/autonomous_behavior.md`](../ideas/autonomous_behavior.md) is the +holding pen — a 16-state machine on an energy/mood model, novelty-grid exploration, ToF +avoidance, startle, sound reactions, ball play, a nap cycle, petting. + +Every input it needs now exists, and some it never had: ambient sound events, depth frames, +classified trunk-frame obstacle points, voice tags, nearby ducks by stable id, a shared beat, +RSSI as coarse distance, a live synth voice, hand distance from the ToF. + +**The shape matters more than the states.** Presence, mood and the beat are *inputs to one +brain*, not modes beside it. The chorale and the theremin grew as explicit modes because there +was no brain to hang them on — the chorale is 55 KB of `robotd` — and that is the pattern this +milestone exists to stop repeating. It gets a design doc before it gets code. + +**Done when:** a duck left alone in a room does something worth watching for ten minutes, and +the chorale is something it decides rather than something a command starts. + +## The order of work + +The numbers above are identifiers. This is the order. + +1. **M8, the model channel.** The next feature. Nothing else is blocked by it, and it unblocks + the loop that produces the robot's actual behaviour: train, publish, install, try, roll back. +2. **M5's transport investigation.** Cheap, and its answer decides whether the Python client is + fifty lines or a project. +3. **M6, as shipping approaches**, by lead time: where a customer robot downloads from, then the + PIN, then the recovery net's hardware test. Consent can land any time and should land early. +4. **M7** — an investigation with no date. It earns priority the first time a duck in someone + else's hands develops a fault nobody can name, and the cheapest way to be ready is to decide + it alongside #52 rather than after it. +5. **M9, the brain** — later, deliberately. It is the largest piece of work left and the one + most likely to grow while being built. ## Decisions that shape work rather than follow it -1. ~~**Signing key custody**~~ — **done.** Three encrypted release keys plus an - unencrypted dev key in `~/.duck-keys`, all round-trip verified; only `release-1` goes into - secrets. Releases are signed in CI under `environment: release`, which on this plan scopes - the secrets but **gates nothing** — no required reviewers, no branch policy, so anyone with - push access can reach the key. Accepted deliberately while no robot is in the field, and the - declaration is the hook that turns a real gate on with one settings change. See - [`ci-setup.md`](ci-setup.md). -2. **Safety authority** (§6) — pulled into M3 for the reason above. -3. **Provisioning** — still needed, but for less than it was. Identity no longer waits on it: - a robot derives one from its own SoC serial and names itself `duck-c51b`, so a board flashed - by hand is distinguishable out of the box ([`app-path-design.md`](../design/app-path-design.md) - §8.2). What is left is calibration and the PIN — BLE pairing security *rests* on a per-robot - PIN, the factory default is `000000` and public in this repository, so out of the box pairing - proves physical presence and nothing more. Something has to generate one, print it, and record - what was printed. Note the PIN cannot come from the identity, which was the plan: the identity - is published in an advertisement, so anything derived from it is public too. -4. **Privacy** — consent + indicator in M5, not M6. +1. ~~**Signing key custody**~~ — **done** for the daemon. Three encrypted release keys plus an + unencrypted dev key in `~/.duck-keys`; only `release-1` goes into secrets. Releases are signed + in CI under `environment: release`, which scopes the secrets but **gates nothing** — no + required reviewers, no branch policy. Accepted deliberately while no robot is in the field, + and the declaration is the hook that turns a real gate on with one settings change. See + [`ci-setup.md`](ci-setup.md). **Reopens with M8**: publishing a policy is a third kind of key. +2. **Safety authority** (§6) — landed in M3. +3. **Provisioning** — identity is done; calibration and the PIN are not, and the PIN is a factory + process rather than code (M6). +4. **Privacy** — consent is M5 and unblocked; the indicator is a hardware question and should be + asked as one. +5. ~~**Where a customer robot downloads from**~~ — **decided 2026-08-26:** publish this + repository, and a robot downloads from it directly. §6.1 keeps the other options and the + reasoning, because they are the fallback if the source ever has to close again. +6. **Whether policies leave the daemon artifact** — **leaning yes**, not settled. They leave, a + missing model reports `degraded` rather than unhealthy so the gate commits, and provisioning + installs the bootstrap set. M8 says what the alternative buys if this turns out fragile. +7. **Curated models or an open set** — open, and it is a trust decision rather than a plumbing + one (M8). ## Not doing, on purpose -Recorded so they stay decided: A/B image updates, OS/kernel OTA, fleet -dashboards/telemetry, delta updates, staged rollouts, hardware capability matrix, -competing model alternatives per slot (§17), peripheral firmware OTA (§11.1). +Recorded so they stay decided: A/B image updates, OS/kernel OTA, fleet dashboards/telemetry, +delta updates, staged rollouts, hardware capability matrix, competing model alternatives per +slot (§17), peripheral firmware OTA (§11.1). diff --git a/docs/robot/install-dev.md b/docs/robot/install-dev.md index ad1afd2a..e7d73f3f 100644 --- a/docs/robot/install-dev.md +++ b/docs/robot/install-dev.md @@ -35,8 +35,9 @@ ssh-copy-id radxa@192.168.1.42 advertising yet. - **ssh key access**, from the step above. Provisioning reboots the board and reconnects by itself, and a password prompt cannot survive that. -- A **GitHub token**. This repository is private, so its release assets are unreachable without - one. +- A **GitHub token**, while this repository is private: its release assets are unreachable + without one. Once it is public the token is optional and buys only a higher API rate limit + (`docs/design/updater-design.md` §6.1). - A **clone of this repo**. The dev key it needs is committed at `deploy/dev-key/team.dev.pub`, so there is nothing to ask anyone for. @@ -220,7 +221,8 @@ sudo systemctl restart updaterd ## The token, by hand `scripts/install.sh` writes this for you when given `DUCK_TOKEN`. These steps are for a board -provisioned some other way. +provisioned some other way — and they are only needed while this repository is private, or on a +board that fetches often enough to want the higher rate limit a token buys. `updaterd` reads `GITHUB_TOKEN` from its own environment, so exporting it in your shell does not reach the daemon — it needs a systemd drop-in. @@ -252,8 +254,10 @@ sudo systemctl daemon-reload sudo systemctl restart updaterd ``` -A token on a *developer's* board is fine. A token on a customer robot is not, and is why -artifact hosting is still an open question — see `docs/design/updater-design.md` §6.1. +A token on a *developer's* board is fine. A token on a customer robot is not — a fleet-wide +credential in an image cannot be rotated without reflashing — which is why the answer is a +public repository rather than a shipped token (`docs/design/updater-design.md` §6.1). A board +with no token can still install from a local directory or a dev push. ## Installing without a network