81 Commits

Author SHA1 Message Date
Pierre Rouanet
e965616a8e A hook step must be cheap when there is nothing to do, and one was not
§9.1 said a hook is where a board-setup step belongs and never said what such a step
costs. It runs on every board on every update, so "already done" is the normal case and
the price is paid on every future release rather than once — and the hook phase gains a
step per feature while the budget does not move. The budgets, now written down: 600s for
pre-install, which is `UPDATE_MAX_SILENCE_SECONDS` and therefore a contract with every
client rather than a private figure, and 120s for post-install, shared with the units, the
accounts and the restart.

Three of the four scripts already obeyed this and said so one comment at a time.
`setup-npu.sh` stamps the runtime version and compares it; `setup-gstreamer.sh` compares a
stamp and two `dpkg -s`; `setup-npu.sh` also skips the whole overlay path when a driver is
already bound, so a re-run does not touch /boot at all. The corollary states the shape
those three arrived at independently.

`setup-rkaiq.sh` did not: it ran `gcc -shared -fPIC -O2` on every update, on every board,
for ever. It now keeps the built source at /usr/local/lib/rkaiq-modinfo-shim.c the way
`setup-npu.sh` keeps its `.dts`, and rebuilds when that copy no longer matches — so a
release that changes the C file still gets a new shim, and one that does not pays a `cmp`.

**Keyed on the source and not on the kernel, which the shim's own header argued against.**
It said the object is built on the board because "the struct size it probes for is a
property of the running kernel, so a binary built anywhere else would be guessing". The
size is a property of the running kernel, but `probe_kernel_req` brute-forces it at
*runtime* on the first intercepted ioctl and caches it in a static; the includes are
libc's. The object is aarch64 and nothing more. Corrected there, because that sentence is
exactly what stops the next person adding this gate.

The python pass over the IQ file stays unconditional. It is one interpreter start against
a file that is already correct, it prints which of the two cases it found, and gating it
would cost more in machinery than it saves.

Assisted-by: Claude:claude-opus-5
2026-08-27 16:54:31 +02:00
Pierre Rouanet
8725fbede2 The login-shell files arrive by update, and a test says they must
`install.sh` wrote three files nothing else writes: the `robotctl` completions, the motd
banner, and the `/etc/profile.d` snippet putting the robot's name in the prompt. It runs
once, on a board being set up, and it is not in the artifact — so no hook could run it
even if one tried. The board this was found on has been taking updates since 19 August
with the banner and the completions from that day and no prompt snippet at all, because
the prompt landed on the 26th and boards do not get provisioned twice.

`scripts/setup-login.sh` is those three functions, moved whole. `install.sh` runs it out
of the installed release — the copy a signature was checked against, like the sysusers
files — and `hooks/postinstall` runs it on every update, which is the caller that matters:
it is what puts a file on a board provisioned before that file was written. Idempotent by
construction, since each step rewrites its own file, and never fatal, because a shell
prompt is not worth rolling an update back for.

Moving them fixed an unrelated wart on the way: the comment explaining why the completions
are a loader rather than a snapshot had drifted forty lines from `install_completions` and
sat above the banner instead.

**And the test, which is the point.** §9.1 has now been broken four times and the fourth
happened the day before the rule was written down, so a fifth was a matter of time.
`every_install_sh_step_reaches_an_updated_board` reads `install.sh`'s call sites and fails
unless each one is either performed by a hook too or written down, with a reason, as
something only a fresh install does; its second half fails if `install.sh` runs a
`setup-*.sh` out of the release that no hook names. Both halves were confirmed to fail by
breaking them. It is a forcing function and not a proof — the escape hatch is a line in a
table — but every one of the four would have had to argue for that line.

`board-test.sh` deletes the three files before the postinstall-alone run and asserts the
hook puts them back, and drives the prompt snippet rather than only checking it exists:
it cannot edit PS1 directly, so it hooks PROMPT_COMMAND and rewrites PS1 at the first
prompt, and that indirection is the half that silently does nothing when it is wrong.

One gap stays open and is now named where the accounting is: `hooks/postinstall` installs
units but not the journald drop-in and not the `robotctl` symlink. That asymmetry is
deliberate and `board-test.sh` pins it, so it is not changed here.

Assisted-by: Claude:claude-opus-5
2026-08-27 16:43:54 +02:00
Pierre Rouanet
8791b145b4 The install-path rule, reachable from where it gets broken
§9.1 was written the day after the fourth instance of the thing it forbids merged,
and would not have caught it. Two reasons, both in the docs rather than in the rule.

**It had one inbound link, from the paragraph above it.** Every pointer in the tree —
`hooks/postinstall`, `updater/src/engine.rs`, `updater/src/robot.rs`, four design docs,
the docs index — goes to `install-path-gap.md`, a closed retrospective that then
forwards to §9.1. A reader has to know to take the second hop, and nobody editing
`install.sh` was ever on the first. The hooks and the index now name the rule; the
retrospective says outright that it is the story and §9.1 is the rule.

**And "needs" is a judgement that let the case through.** A snippet putting the robot's
name in the prompt is not something a release *needs*, so it went into `install.sh` and
nowhere else, and a board that has only ever updated does not have it. §9.1 asks the
mechanical question instead — does `scripts/install.sh` write this file or run this
command — which can be answered by grepping one file. The heading changes with it.

The fourth instance is written down, including the way it looked correct while it was
being written: `install_completions` and `install_login_banner` sit on the two lines
above `install_name_prompt` and have the same gap, so the surrounding code read as the
pattern. Three functions in a row that only a fresh install runs are not a precedent.

`install.sh` now says this at that call site, which is the one place all four authors
would have been standing.

The corollary list grows the one the other three are instances of — anything a fresh
install does to a board, a hook does too — and says plainly that it is the only one
without a test, and the only one that would have caught all four.

Assisted-by: Claude:claude-opus-5
2026-08-27 16:13:57 +02:00
Pierre Rouanet
a4be5a9425 The rule the install path keeps teaching, written where it is read
`updater-design.md` §9 described the hook contract — when it runs, what it gets,
what a non-zero exit costs — and never said what a hook is *for*. Its "typical
uses" line said config migrations, udev tweaks and data conversions, which is a
list narrow enough to read as "not this" for every case that has actually gone
wrong.

§9.1 states it instead: a release is not installed until everything it needs is on
the board, and the hook is the only thing that runs on every board on every update.
Shipping a file into the release directory is not installing it; shipping a script
there is not running it.

Three instances, in three shapes — units left where systemd never looks (btd,
203/EXEC), a GStreamer stack only provisioning installed, and now a setup-npu.sh
packaged beside the model and never called. The shape is identical each time: the
work was done, and the thing that makes the work reach a board was left out. It
survives review because the diff that adds the script looks complete.

The corollaries each name the test that enforces them, and the last one is why
none of this may be fatal: a board with no camera, no radio or no NPU is still a
robot, and a hook that failed the update over one would cost more than it saved.

Also here, both found by writing it:

- `install-path-gap.md` is under docs/project/, and three source comments plus the
  postinstall header pointed at docs/. The retrospective now points forward at the
  general rule rather than being the only place it is written down.
- §9's claim that successful hook output is discarded, and the matching open
  question in §17, were closed when hooks.rs started logging line by line. That is
  the same fix for the same reason, and it was found the same way: a grep of the
  journal for a hook that had plainly run came back empty.

Assisted-by: Claude:claude-opus-5
2026-08-27 14:49:50 +02:00
Pierre Rouanet
a60e87c0b6
Merge pull request #158 from pollen-robotics/roadmap-rewrite
Roadmap: the first one reached its target, so this is the next one
2026-08-27 11:57:25 +02:00
Pierre Rouanet
4283e0ef4a Say what was decided, not what is about to happen
Everything the rewrite added about going public was written in the future
tense — "the repository goes public", "until it does", "until this week" — and
all of it reads wrong the day the flip happens. The decision is what these docs
record, so they now record the decision: *publish this repository*, phrased so
it is true on both sides of the switch and needs no second pass afterwards.
§6.1's heading loses its "so this one goes public" for the same reason.

`install-dev.md` had the sharper version of the problem: it walks a reader
through baking a GitHub token onto a board and `chmod 600`-ing it, listed as a
flat prerequisite. Once the repo is public that credential buys nothing but a
higher rate limit, and a reader following the steps unchanged would put one on a
board for no reason. The prerequisite and the by-hand section now say when a
token is actually needed — while the repo is private, or for the anonymous API
budget §6.1 documents. `provision-board.sh` already said this ("only while the
repository is private"); the docs were behind the script.

Two smaller things a public reader would hit:

- The link to `microduck_rl` 404s for anyone outside the org, and it is M8's
  central artifact. De-linked and named as private, rather than pointing a
  stranger at a page they cannot open.
- M6 no longer carries a delivery estimate. "Preorders are open" is the whole
  reason the section orders by lead time; "shipping is a few months out" is a
  schedule commitment in a versioned file, doing no work in the argument.

Not touched here, because it is a decision rather than an edit: `ci-setup.md`
states in bold that anyone with push access can read the release signing key,
and why no protection rule can be turned on. The roadmap links to it. That goes
public in the same flip and is worth settling first.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-27 11:48:44 +02:00
Pierre Rouanet
a0f9f9b3c9
Merge pull request #157 from pollen-robotics/three-papercuts
Three papercuts, and the two things one of them was hiding
2026-08-27 11:30:17 +02:00
Pierre Rouanet
2ac8db19e6 Expose congestion control as [media] congestion_control
`webrtcsink`'s `congestion-control` was never set, so every robot ran the
element's default and there was no way to change it short of a code edit.

It matters more than its name suggests, and the reason is CPU rather than
network. Per-thread on the board with one peer connected, `rtpgccbwe` is the
largest single consumer in the process — 7.6% of a core, against `v4l2src`'s
0.3% and the raw branch's 0.3%. The estimator works per packet where capture
works per DMABuf handle, so it is packet rate, not pixel count, that this
daemon spends CPU on. That is also why picking a smaller rung does not lower
load: on a link that never saturates, the estimator ramps a 360p stream to
about the bitrate the 720p one was using and spends it on quality per pixel.

`disabled` deletes that thread, at the cost of the adaptivity that is the whole
reason `webrtcsink` is handed raw video rather than pre-encoded H.264 — on a
degrading link it is what keeps a picture instead of a stall. It also makes
`bitrate` mean what it says: nothing moves it, so it is the rate rather than a
starting point. `homegrown` is the element's own sender-side heuristic in
between.

The labels are `webrtcsink`'s own property nicknames rather than names of ours,
because they are what gets set on the element — `gcc`, not `googcc`, which is
pinned by a test: a nickname spelled our own way would be a key that parses,
validates, saves and then silently leaves the element on its default.

`gcc` is the element's default, so this changes nothing on any robot today.
Naming it is the point — the day upstream changes that default should not be
the day every robot's send rate changes with it.

Set defensively: this is the only value in `start` that comes from a config
file rather than a literal, and `set_property_from_str` panics both on a
missing property and on an unknown nickname. A panic there is a daemon that
will not start, costing the video and the control channel to gain a setting, so
the property is looked up and the nickname resolved through the enum's own
class — either failing warns and leaves the element on its default.

Assisted-by: Claude:claude-opus-5
2026-08-27 10:18:57 +02:00
Pierre Rouanet
f7de13a227 Configure the video stream from robotctl configure
What `mediad` streams — camera or test pattern, frame size, rate, bitrate —
was four clap flags, of which the unit file set exactly one. The release
installer rewrites `mediad.service`, so changing any of them meant a systemd
drop-in: a mechanism for a board that is wired differently, not for someone
asking why the picture is soft. In practice every robot ran the defaults.

They are now `[media]` in `/etc/robot/robotd.toml` — the per-board config file
that already survives an update and a rollback, and that `robotctl configure`
already edits. `mediad` reads it through `robotd-params`, the same crate
`robotd` parses it with and the same one the editor writes through, so the
schema, the defaults, the validation and the editor cannot drift apart.

One `quality` key naming a rung (`1080p30`, `720p30`, `720p15`, `360p30`)
rather than a width, a height and an fps: those three do not vary
independently, and a combination the capture path cannot produce is a pipeline
that does not start — which costs the WebRTC *control* channel along with the
video, since the datachannel is bundled with the video track. Every rung is
16:9, so "smaller" never quietly means "cropped". `bitrate` stays settable on
its own and follows the quality when unset. 720p30 with a 2 Mb/s start is
exactly what the flags defaulted to, so a board with no `[media]` section
streams what it streamed before.

The restart offer is now derived from the keys that changed rather than
assumed: `[media]` is `mediad`, everything else is `robotd`, both in the
`After=` order. Offering a `robotd` restart for a video setting is an edit
that reads as having done nothing at all.

`media.camera` replaces the documented `--camera` drop-in, so a board with no
camera is fixed the same way everything else is.

Verified: `cargo test --workspace --exclude tof` green, and
`RUSTFLAGS=-D warnings cargo clippy --target aarch64-unknown-linux-gnu` clean
for the three crates touched — which is what caught `session::Video`, the
second consumer of the geometry, still reading the removed flags.

Assisted-by: Claude:claude-opus-5
2026-08-27 08:44:18 +02:00
Pierre Rouanet
b516798c5f Two decisions the rewrite was written just before
**The repository goes public**, which closes §6.1. It was the longest-lead item
in M6 on the grounds that a robot shipped pointing at the wrong host cannot be
updated; going public means there is no second host and no token in an image.
The section keeps the options and the reasoning rather than deleting them —
an artifact-only public repo is the fallback if the source ever has to close
again — and records what going public does *not* change: the engine's API path
is already the one path for private and public alike.

What it does leave is a budget, written down where someone will hit it.
Anonymous GitHub API requests are capped at 60 an hour per IP and a home robot
has no token, so its checks spend from the pool shared by everything behind that
address. One duck at `check_interval = "6h"` is nowhere near it; a room of
twenty on one wifi is not. Not a correctness problem — `http.rs` already reads
403 and 429 as "come back later" — but a reason to fetch bytes through
`browser_download_url` once public, since object downloads spend nothing from
that pool.

**Models: browsing is a trust decision, not a plumbing one.** The ask is basic
policies of our own plus an easy way to look at and download others, and the
second half is what needs deciding: every artifact the engine installs is
verified against a trusted key, and a stranger's model is signed by nobody this
robot trusts. Recorded as three things to settle inside M8 — curated org or an
explicitly unverified path that is off by default and refused on a customer
robot the way dev keys are; the `obs[1,61] -> actions[1,14]` shape check becoming
a hard gate whoever signed it; and the fact that makes it allowable at all,
which is that the safety layer holds the only write handle to the bus.

Slots stay fixed and sources do not: repointing `walk` at another repo keeps
rollback, pin and known-bad per slot, where letting arbitrary components appear
at runtime would mean the config is no longer the authority on what a robot may
run.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-26 17:41:06 +02:00
Pierre Rouanet
8b1b7e5109 Three papercuts, and the two things one of them was hiding
Each was found by the docs audit (#154) and written down there rather than
fixed, because a docs change is not the place for code.

**`cargo test --workspace` now works on a Mac.** `vendor/platform.c` reaches
the bus through `linux/i2c.h`, so `build.rs` compiles it only when the *target*
is Linux — read from `CARGO_CFG_TARGET_OS`, since a build script is compiled
for the host and `cfg!` there would answer for the laptop. The driver's calls
go with it, and off a board `Sensor` is uninhabited: `open` is the only
constructor and it always fails, so the compiler discharges every other method
instead of leaving a body that could one day invent a frame. `tofd` still
builds, `tofd --fake` still serves, and 942 tests pass with nothing excluded.

**`install.sh` stops reporting `tofd` as a daemon it forgot.** The hook enables
every unit with an `[Install]` section a step earlier, so the warning was noise
on every fresh install. Named as known, with no `enable_unit` of its own:
nothing depends on it, so there is no ordering to have an opinion about.

**`dev-push.sh` checks all seven daemons**, not six — and the comment saying
five, restarted in a set of three, was two daemons out of date.

That last one could not work on its own, and finding out why turned up two
faults rather than one:

- **`tofd` published no identity.** Six daemons call `log_startup_identity!`;
  `tofd` had a hand-rolled `warn!` that logged a version and wrote no
  `/run/tofd/identity.json`. That macro exists because `padd` was once the one
  daemon whose journal could not say which build was running — and its own doc
  comment predicts this: "a shared definition makes the next daemon's omission
  a missing call rather than a missing idea". `tofd` was the next daemon.
- **`configd::units::MANAGED` was still five units.** `mediad` and `tofd`
  shipped units two releases ago. So the `units` block of `robotctl health` —
  the one a person reads after an update to find which daemon is still on the
  old release — could not report either of them at all. Same defect as the
  restart set in `restart-order.md` that #154 fixed, one file over.

Docs follow the code: the macOS caveat in CONTRIBUTING, the `tofd` note in
`restart-order.md` §6, and `dev-push.md`, which claimed `robotctl version`
could answer for `tofd` — it could not, for both reasons above.

Verified: `cargo test --workspace` 942 passing on macOS with no exclusions,
`cargo board -p tof --bins` still cross-compiles the driver for the board,
clippy clean for what this touches, `shellcheck -S warning` clean on both
scripts.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-26 17:14:37 +02:00
Pierre Rouanet
b315ae46bb docs: make the reference pages describe the seven daemons that exist
Six crates and two daemons landed after the pages that describe the system
were written. `mediad` and `tofd` ship units with `[Install]` sections;
`kinematics`, `odometry`, `sounds`, `pet-detect` and `robotd-params` are
libraries `robotd` drives. The docs still said five daemons and `mediad`
not built.

The one that would have cost someone an afternoon is `restart-order.md`:
it named `configd, padd, robotd` as the restart set, which is the page you
read while diagnosing a version skew. It is `configd, mediad, padd, robotd,
tofd` — derived from shipped units, exactly as that page's own rule says,
and `deploy/updater.toml` had said so for two releases.

Also:

- `architecture.md`: the overview and the diagram, which still boxed
  `mediad` as not built while §1's own table described it.
- `robotd-design.md`: odometry left the deferred list — `monitor`'s path
  map reads it now, which was the objection. New §4.4 for it, and §4.5 for
  the voice, theremin, chorale and pet detection, saying why none of them
  earns a design page and where each one's reasoning lives.
- `CONTRIBUTING.md`: 508 tests was 936; 13 board checks were 60. Neither
  Linux nor macOS builds `cargo test --workspace` out of the box any more —
  Linux wants libudev and GStreamer, and `tof` does not compile on a Mac at
  all. Both written down, with the commands.
- `roadmap.md`: M5 is in progress rather than unstarted, and the crate
  layout defers to CONTRIBUTING rather than keeping a second copy.
- `docs/README.md`: `pad-minimal-pairing.md` and the whole `ideas/`
  directory were off the index.
- `cheatsheet.md`: the monitor's 3D robot view, its ToF contact points and
  the odometry path map were documented nowhere. `d`, `[`, `]`.
- `_build-release.yml`: a comment claiming `mediad.service` has no
  `[Install]`, two releases after it grew one.
- two dead links: `updater.example.toml` from `updater-design.md`, and
  `README.md#drive-it` from `pair-a-gamepad.md`, whose target the README
  rewrite removed.

Assisted-by: Claude:claude-opus-5
2026-08-26 16:35:17 +02:00
Pierre Rouanet
866219022a docs: a hook change takes effect one apply later
`updaterd` does not restart itself mid-update, so every step of an apply is executed by the build
that was already running. The hook *script* comes from the incoming release; the code that runs it,
logs it and bounds it comes from the previous one — which is why a fix to hook logging landed, the
apply carrying it ran, and the journal still had nothing to show.

Written down next to the table it belongs to, with the command that answers it.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-25 18:02:12 +02:00
Pierre Rouanet
ae8130666d preinstall installs the GStreamer stack, which was always the plan
`setup-gstreamer.sh` has said so since it was written — "the download happens during provisioning
and, later, from the updater's preinstall hook, which runs with a cleared environment and no token",
which is why the plugins repository is public. The hook half was never written, so a board
provisioned before that script existed needed a command somebody remembered, and a board whose
plugins predate the version this release was built against failed with a missing element in a log.

The hook runs the release's own copy of the script rather than a second implementation of it. That
script owns the pinned plugins version, the Debian package list, the Rockchip MPP and RGA debs, the
udev rule for `/dev/mpp_service` and the encoder report — six things that would otherwise exist twice
and drift, which is the class of bug this repo keeps writing down. It is idempotent by design, so a
provisioned board pays a stamp comparison and two `dpkg -s` calls.

**Never fatal**, and that is the difference from the ONNX Runtime step above it. A release that
cannot load a policy is a robot that cannot stand, so that one aborts the update with the old release
still live. A board with no camera stack loses `mediad` and still walks, pairs and updates — refusing
the update over it would mean a board that cannot be fixed by the mechanism that fixes boards.

**The pre-install hook gets ten minutes instead of two.** It is the hook that installs what the
release needs and the board may not have, and on a board that has never had the media stack that is
around 100 MB of apt over whatever wifi the robot is on. It can afford the minutes precisely because
nothing has been swapped yet: the old release is still live and serving, so a long pre-install is a
slow update rather than a robot in an unclear state. The post-install hook keeps its two minutes,
where the same wait would sit between the swap and the restart.

Packaged at all three sites, with an xtask tripwire — `every_script_the_hooks_run_is_packaged` — for
the failure this would otherwise have: a script referenced and not packaged makes the step a no-op
that says so in a log nobody reads, on exactly the boards it exists for.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-25 17:22:40 +02:00
Pierre Rouanet
bbe68aacc7 mediad gets its [Install] section, so a release brings it up
It shipped without one so that a board could carry the binary without running it, "until it has
proven itself over a few boots". It has run, and the gate had turned into a permanent manual step:
`hooks/postinstall` skips `enable --now` on a unit with no `[Install]`, `units_shipped` skips it when
an apply restarts daemons, and `install.sh` warned that it did not know where the unit belonged. So
every push ended with somebody typing `systemctl restart mediad`, and every board provisioned in the
meantime accumulated a bring-up step nobody remembers.

One rule does all of it — a unit with an `[Install]` section is enabled on install and restarted on
apply — so this is the section, and the removal of everything that stood in for its absence:

- `install.sh` starts `mediad` after the daemons it forwards to, allowed to fail like `btd` and
  `padd`, with the GStreamer command in the warning.
- `dev-push.sh` loses the `deferred` list, the `[--] nothing restarts it` branch and the exit-3 path
  that existed so the closing line would not contradict them. A stale `mediad` is now a `[FAIL]`
  like any other daemon, because now it is one.
- `deploy/updater.toml`, `updater.example.toml` and `updater-design.md` stop saying `mediad` joins
  the restart list when it exists. It needs no entry at all: the set is derived from the units the
  release ships, and `units` is only for units it does not.
- `robotctl`'s camera line and `monitor`'s caption still omit themselves when `mediad` published
  nothing, which is still right — but the reason is a board with no camera, not a daemon that ships
  disabled.

**Two ways this can now fail on a robot nobody is watching**, both reported by `robotctl health` as a
unit that is not running, neither touching the control loop, BLE, the pad or an update:

- a board provisioned before `setup-gstreamer.sh` existed has no plugins, so the pipeline cannot
  start and `Restart=always` retries every five seconds. `sudo /usr/local/sbin/robot-setup-gstreamer`
  fixes it. Provisioning has installed the stack by default since before `mediad` shipped, so this is
  only boards older than that.
- a board with no camera, because `ExecStart` carries `--camera`. The unit's own drop-in is the fix.

And one behaviour change on every robot: an update now restarts `mediad`, which drops a live WebRTC
session. `remote-webrtc.md` §8 already describes the gate that will hold an update while a session is
active; nothing sets that flag yet.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-25 17:14:10 +02:00
Pierre Rouanet
9164c7623b docs: webrtc-console is what shipped, not what was proposed
The four changes are in, so the page says so and points at the code that now owns each answer. The
reasoning stays: the alternatives are the part worth being able to re-read, and §1.3's sequence —
two ports now, our own signalling server and a certificate on the day audio or a browser gamepad is
wanted — is still ahead of us.

§7 also names the one piece deliberately left out: `dev-push.sh` still resolves a board with
`wifi status` and embedded Python, and switching it to `duckctl ip` touches the push path.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-25 16:51:36 +02:00
Pierre Rouanet
d9da9b5ca8 Merge remote-tracking branch 'origin/main' into webrtc-console 2026-08-25 16:22:41 +02:00
Pierre Rouanet
35c6d1ca8b
Merge pull request #142 from pollen-robotics/repo-rename-microduck
Follow the repository rename to pollen-robotics/microduck
2026-08-25 16:15:48 +02:00
Pierre Rouanet
4db827c11d scripts, docs: follow the repository rename to pollen-robotics/microduck
The repository is now `pollen-robotics/microduck`. Every hardcoded default
still named `pollen-robotics/microduck_daemon`: the `DUCK_REPO`/`ENV_REPO`
defaults in `install.sh`, `provision.sh`, `setup-board.sh` and
`migrate-network.sh`, the raw URL `provision-board.sh` hands the board, the
repository `board-test.sh` substitutes into `updater.toml`, and the
copy-paste bootstrap lines in `deploy/README.md` and the `install.sh` header.

Nothing was broken: GitHub keeps the old slug alive — raw.githubusercontent
serves it directly, and `api.github.com/repos/<old>/…` 301s to the numeric
repository on the same host, so the bearer token survives the redirect and
`updater`'s five-redirect budget follows it. Boards already carrying the old
slug in `/etc/robot/updater.toml` keep updating for the same reason; they
only break if someone else claims the old name.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-25 16:03:56 +02:00
Pierre Rouanet
1c63dc2f09 webrtc-console: the rename is decided, so the page uses the name
`duckctl` throughout, and §3 keeps its reasoning while saying it has landed.
Two corrections from doing it: `roadmap.md` is in `docs/project/` but is not
a record of a moment, so it was updated; and what keeps the crate off the board
is `default-members`, which is worth naming because the alternative was two
`--bins` call sites kept in step by hand.

Assisted-by: Claude:claude-opus-5
2026-08-25 16:02:02 +02:00
Pierre Rouanet
8cb891f951 webrtc-console: the client is not a Bluetooth tool for much longer
`open` is a browser launch on a tool called btctl, and pulling on that reaches
something bigger than the one command: `mediad` is a second transport, the two
permit different method sets by design, and the tool a person wants is one that
knows which transport can serve a call rather than a choice of binary.

So: `duckctl`, in its own crate rather than an example of the BLE daemon —
which also retires `cargo install --path btd --example` and the fallback
`dev-push.sh` carries for it. No shim, and `docs/project/` keeps saying
`duck-btctl`, because those are records of a moment.

Assisted-by: Claude:claude-opus-5
2026-08-25 16:01:20 +02:00
Pierre Rouanet
2abad0c95d webrtc-console: axum decided, and two ports the user never counts
axum over a hand-rolled responder, and two ports on the condition that neither
of them is ever a step: one address typed, the signalling URL filled in by
`mediad` as it serves the page rather than compiled into it, and the one
failure two ports can produce named in words.

The commands are `ip` and `open`. `ip` is not a new idea — `dev-push.sh`
hand-rolls it today through six lines of Python embedded in a shell script, and
reading the advertisement instead removes the bond, the PIN and most of the
wait. With the fallback that makes it work on the bonded Mac that needs it most.

Assisted-by: Claude:claude-opus-5
2026-08-25 16:01:20 +02:00
Pierre Rouanet
4917771546 docs: the WebRTC client, from a test page to the robot's console
The page proved the transport and stops there: it needs `python3 -m http.server`,
a URL typed by hand against a hostname every board shares, and it reaches almost
none of what `route.rs` permits.

Four changes, each standing alone — `mediad` serving the page from the binary,
the producer meta, `duck-btctl` handing over the address `btd` already
broadcasts, and the page itself. Plus the one thing that shapes the order: a
microphone needs a secure context, which a robot on plain http cannot be.

Assisted-by: Claude:claude-opus-5
2026-08-25 16:01:20 +02:00
Pierre Rouanet
baf912e236 duckctl: the client is named for the robot, not for the radio
`duck-btctl` was an example of `btd`, and both halves of that stop fitting.
`mediad` gives a robot a second transport, and the two reach different method
sets by design — `robot.move` is refused over BLE and permitted over WebRTC,
`net.connect` the other way round — so a tool named after one radio teaches
everyone that it is the way in, at the moment it stops being the only one.

Its own crate, because a transport-agnostic client cannot be an example of the
BLE daemon: it would need btleplug and a WebSocket client, and only one of those
belongs to `btd`. The guarantee the example arrangement bought — btleplug never
in a release — survives as something stated rather than incidental: nothing on
the robot depends on `duckctl`. `advwatch` comes too; it is a scanner, and
scanners run on laptops.

`default-members` is what keeps it off the board. `cargo board --bins` builds
every default member for aarch64, and cross-compiling a Bluetooth stack for a
board that must never see one is both wasted and wrong. One list, rather than
naming binaries at each of the two `--bins` call sites.

No shim: `duck-btctl` is gone rather than aliased. `docs/project/` keeps
saying it, because those are records of a moment and a tool did not have this
name when they were written — their links are repointed, their prose is not.

Assisted-by: Claude:claude-opus-5
2026-08-25 16:00:36 +02:00
Pierre Rouanet
66563d1acd docs: the camera streams at 29.3 fps, and what it took
Two independent causes of the same 35% frame loss, neither of which moves the number alone: a
capture pool that rkisp's missing V4L2_CID_MIN_BUFFERS_FOR_CAPTURE leaves at two buffers, and
v4l2src preferring a two-plane NM12 it cannot drive at full rate.

Also recorded: what was ruled out by measurement, and the four different rates that were each
mistaken for the capture rate along the way. That last part is the reusable lesson — every one of
them measures something downstream of a queue that drops.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-25 15:02:36 +02:00
Pierre Rouanet
161911e026 mediad: offer H.264 only, now that H.264 can be offered
The restriction was held back while H.264 was missing from the offer, because restricting to a
codec that fails discovery leaves no codecs at all. The cause is now understood and fixed
upstream of here: mpph264enc's src pad template omitted `constrained-baseline`, the one profile
webrtcsink's discovery pass demands, so discovery failed and VP8 won by default. Plugins release
v3 carries the one-word patch.

With it in place, webrtcsink no longer proposes vp9enc and av1enc — software encoders on four
Cortex-A55s, which for a browser that prefers AV1 is not a degraded stream but a dead control
loop. It also cuts the discovery pass from six codecs to one, which makes its log readable.

No `profile` field in the caps: webrtcsink reads one off them and `expect()`s it against a fixed
list, so an unknown value panics inside the plugin. The profile belongs on the encoder, which is
where wire_encoder_setup already sets it.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-25 11:33:25 +02:00
Pierre Rouanet
6a4e4b912f docs: it works on hardware — video and a control channel to a browser
A Radxa Zero 3W streams videotestsrc through mpph264enc to a browser on the LAN,
negotiated as profile-level-id=42e01f — constrained baseline, which was the
point of setting the encoder's profile rather than inheriting High — with the
control datachannel bundled onto the same transport (a=group:BUNDLE video0
application1, a=sctp-port:5000).

Records the two failures that got there, because both are the shape of bug this
design invites rather than one-offs. A panic in a GStreamer signal handler
aborts the process instead of unwinding, and says nothing about why. And a
file:// page cannot reach a private address in Chrome, because an opaque origin
to a private IP is exactly what Private Network Access blocks — so the client's
own instructions now say to serve it rather than open it, which is a correction
to advice I gave.

Still untested and named as such: the control channel carrying a real call, the
camera as a source, and anything through a bridge.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-25 10:34:22 +02:00
Pierre Rouanet
63e470aa0f ci: ship mediad without enabling it, and one sysroot for every board build
Two tripwires fired, both correctly, and the fix for each is the thing they
were pointing at.

`every_sysusers_file_in_the_repo_is_packaged` caught a sysusers file in the tree
that no packaging site installs — "the account it creates will not exist and the
unit naming it will not start". The tension it exposed is real: I did not want
to ship mediad, because postinstall enables every unit that ships, and a daemon
that has never run would restart-loop on every robot in the field.

`[Install]` is the switch, and the repo already had the precedent: postinstall
skips a unit with no `[Install]` section and says so, which is how the boot
recovery net's oneshot ships. So mediad ships — binary, unit, account — and
starts only when somebody runs `systemctl enable --now mediad`. The unit carries
the three lines to add when it has run somewhere, and why they are absent.

`every_binary_a_packaged_unit_execs_is_staged` then caught dev-push.sh packaging
the unit without staging the binary, which would have been 203/EXEC on a board.
Both tests earned their keep.

The other failure was mine: dev.yml and _build-release.yml have their own
cross-build steps, and I had wired the sysroot into ci.yml alone — so they still
pointed pkg-config at Ubuntu multiarch and died in glib-sys. Fixed by replacing
multiarch with the sysroot in all three rather than adding the sysroot beside
it: PKG_CONFIG_LIBDIR replaces the search path while PKG_CONFIG_PATH prepends to
it, so keeping both would have mixed Ubuntu's libudev with Debian's GStreamer,
and only one of those matches the robot.

That makes scripts/ci-cross-deps.sh dead, so it goes, and the prose that quoted
it now names what replaced it. Deleting it is safe to do here rather than later
because ci.yml's board job has already gone green with the sysroot — the step
works, unpacks in about ten seconds, and caches on the hash of the script that
owns the package list.

Assisted-by: Claude:claude-opus-5[1m] shellcheck
2026-08-25 06:41:09 +02:00
Pierre Rouanet
d6eed0d5dc mediad: tee raw NV12 off the capture path, before the encoder
Review feedback, and the placement is the whole point. architecture.md §5.3
wants a frame on demand for a server-side program — "a frame every second or
two plus a state blob", not a 30 fps H.264 track to decode — and §2 wants
perception next to the sensor, deriving features rather than shipping pixels to
robotd. Both want pixels, and taking them off the encoded branch would mean
decoding what was just encoded.

NV12 end to end, pinned with a capsfilter rather than negotiated. It is what the
rkisp path emits and what mpph264enc accepts, so nothing converts anywhere — no
videoconvert, no RGA pass — and pinning it means a raw consumer does not have to
guess a format that would change under it the first time the source did.

Two things about the branch that are easy to get wrong and expensive to
diagnose. Each side of the tee gets its own queue, because a tee without them
pushes to both branches from one thread and the slower one holds up the other —
here a perception consumer would stall the video track. And the raw queue is
leaky-downstream, one buffer deep, feeding an appsink with sync=false: that is
last-value-wins, which is the non-blocking latest-snapshot semantics §2 asks
for. A slow reader costs itself frames and can never cost the encoder any.

`Frames::latest()` is the handle. Nothing reads it yet — perception and
`get_frame` are what it is for — but the branch runs from the start rather than
being added later, because inserting a tee into a live pipeline is a materially
harder problem than having one that was always there.

Frame size and rate become arguments, since both branches now depend on the
answer. Defaults are the 1280x720 at 30 the encoder was measured at.

Cross-builds and clippy-clean on the target; the 12 portable tests are
unaffected.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-25 06:29:27 +02:00
Pierre Rouanet
af18769fe5 mediad: a web client, so the session can actually be tried
One file, no build step, no dependencies: open it from disk and point it at a
robot. It exercises everything mediad does today — the in-process signalling
server, the hardware-encoded video track, and the control datachannel with
JSON-RPC on it.

It speaks the gst signalling protocol by hand rather than through
gst-plugins-rs's gstwebrtc-api, and that is the whole point: a test client that
needs npm is a test client nobody runs. The message shapes are read off
net/webrtc/protocol at 0.15.3 rather than inferred, because they are the one
thing here that fails *silently* when wrong — `peer` carries either an sdp or an
ice flattened alongside sessionId, and `sdpMLineIndex` is camelCase from a
rename_all on the variant. A guess would connect and then never show a frame.

Two preset buttons are there to fail: net.connect and system.pairingPin, both
refused by the route table for different reasons. A refusal arriving with the
right method name in it is what proves the table is being consulted at all,
which a video frame does not.

The client receives the datachannel rather than creating one, matching what
mediad does — it calls create-data-channel per consumer so a peer that opens
nothing still gets a control surface, and a peer that opens its own would get a
second, unrouted channel.

No ICE servers configured, deliberately: this is the LAN case §3 exists for.
Reaching a robot elsewhere goes through the bridge and needs STUN, which is that
design's problem rather than this page's.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-24 19:48:58 +02:00
Pierre Rouanet
7f19712c58 scripts: an aarch64 sysroot, so mediad can be cross-compiled at all
The gstreamer-rs crates are pkg-config crates, so cross-compiling them needs the
target's headers, .pc files and shared libraries on the developer's machine.
ci-cross-deps.sh says of libudev that it "is the cost of that one exception, and
it is worth reading before adding another" — this is the second, and much
larger.

Proven rather than proposed: `cargo board` builds the entire workspace against
it, and gstreamer, gstreamer-app and gstreamer-webrtc resolve at 1.26.2 — the
same version the board runs, because the sysroot is unpacked from the same
Debian archive the board installs from. A probe crate cross-links to a 401K
aarch64 binary whose DT_NEEDED names only libraries the robot has, at a glibc
floor of 2.30 against the board's 2.41.

Three things it got wrong first, all now recorded next to the code:

  - PKG_CONFIG_LIBDIR *replaces* the search path rather than adding to it, so a
    sysroot carrying only GStreamer broke padd — inside libudev-sys, nowhere
    near anything about media. Replacing is still correct: PKG_CONFIG_PATH is
    additive to the host's, which is exactly how pkg-config comes to answer with
    a macOS library and produce a binary that cannot run on the robot. So the
    sysroot serves the whole workspace.
  - Resolving Debian Depends from the obvious roots pulls 543 packages, because
    libgstreamer-plugins-bad1.0-dev declares every optional backend's dev
    package and the closure reaches Qt, Vulkan and OpenEXR. Nineteen explicit
    packages satisfy what is needed, derived by asking pkg-config what was
    missing one round at a time and mapping each answer through the archive's
    Contents index — eight rounds, recorded so it can be re-derived.
  - A -dev package alone is not enough for anything actually linked: it ships
    libfoo.so as a symlink onto the libfoo.so.N in the runtime package. Missing
    that gives "unable to find dynamic system library", which does not sound
    like a packaging problem.

`--check` verifies an existing sysroot against the module list, so a package
list that goes stale fails there rather than inside a build. Also notes trixie's
renames, since both bit: libglib2.0-0 became libglib2.0-0t64 in the 64-bit
time_t transition, and GLib's headers moved out of libglib2.0-dev into
libgio-2.0-dev.

The alternative was building mediad on an arm64 runner like the plugins.
Rejected: it splits the daemon build in two and leaves nobody able to build
mediad on a laptop, which for the crate needing the most iteration against real
hardware is the wrong trade.

Assisted-by: Claude:claude-opus-5[1m] shellcheck
2026-08-24 19:02:16 +02:00
Pierre Rouanet
f29881344a docs: control-only first, and what deferring teleop removes
Teleop is not the near-term priority, so the first version opens the `control`
channel alone — and that is worth stating as a simplification rather than a
deferral. A reliable, ordered datachannel delivers in order by definition, so
intents arriving over it preserve exactly the property `intents.rs` already
depends on. There is no ordering problem to solve in the first version at all.

What it costs instead is head-of-line blocking: on a reliable channel a lost
packet stalls everything behind it, including the control RPCs, so a bad link
shows up as everything pausing rather than as a stale joystick. Fine at a modest
rate, worse with rate and loss — which is why architecture.md §5.2 specifies a
second channel, and why the answer to "laggy over a poor link" is teleop rather
than tuning.

The sequence numbers stay documented for when teleop lands, because that failure
is silent and looks like bad tuning rather than a bug: SCTP with
maxRetransmits=0 reorders, so a twist from 80 ms ago can land after a fresher
one and win last-writer-wins. Trivial to design in, awkward to diagnose from a
stale twist.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-24 18:40:02 +02:00
Pierre Rouanet
73874138e6 docs: the bridge already authenticates, so the robot needs no gate at all
Correcting the rule from the previous commit. It said LAN-only may be
unauthenticated and bridged may not — but the bridge sits behind OAuth, so
reaching the part of it that routes to a given robot *is* proof of account
ownership. The rule asked for a second answer to a question already answered,
and a worse one: a shared 000000 PIN proves less than an account token does.

It is authenticated on both sides, which is the part that makes it solid rather
than merely convenient. The client authenticates to the service with OAuth and
sees only the robots its account owns; the robot's relay connects outward
holding an account token, so the robot proves it belongs to the account too. The
service matches two already-authenticated parties.

What that means is the trust moved rather than vanished, and the doc now names
where it went: the robot has no independent check, so the robot-to-account
binding must be right and lives in the service. That is the only component that
can know the answer, so it is the right place — but it is a dependency, not an
absence of one.

Two things kept because they cost nothing and close real gaps. A robot whose
signalling port is exposed directly to the internet by a port forward has no
bridge to have authenticated anything, and §4's LAN reasoning does not apply
either — a deployment mistake rather than a design decision, and worth saying
because nothing in the robot would notice. And because the relay is a robot-side
process connecting to loopback, the robot *can* distinguish a bridged peer from
a LAN one by source address even though it does not act on that today, so
nothing is foreclosed.

system.authenticate stays documented as the hook, retitled to make clear it is
an available answer rather than a planned one.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-24 18:34:11 +02:00
Pierre Rouanet
78034949f3 docs: LAN-open WebRTC is the decision; the bridge is where that stops
§4 no longer gates the control channel. Anyone on the same network can drive
the robot and see its camera, and the doc says so plainly rather than implying a
gate that is not there.

The argument is stronger than "good enough for now". The pairing PIN is a shared
000000, so a PIN that is identical on every robot authenticates nobody —
requiring it over WebRTC would add a step to every first connection and buy no
safety whatsoever. Usability outranks hardening at this stage, and this
particular gate is cost with no benefit. What it does cost is stated: fine on a
bench and in an office, not fine in a home, which is the thing to revisit before
one ships to one.

The line that matters is not the LAN, it is the bridge. §7 deletes "on the LAN"
as a boundary, so LAN-only may be unauthenticated and bridged may not — and that
is a cheap commitment, because the bridge has to decide which robots a user may
see anyway, and whatever answers that also authorises the session. Deferring
auth is declining to invent a second answer before the first exists, not
deferring work.

`system.authenticate` stays named as the hook, and is cheap to add later
precisely because §5's routing table already needs a notion of which methods a
transport may reach; "which methods before authentication" is the same table
with a smaller subset.

Two consequences followed, one of which was a stale justification:
system.pairingPin stays out of the subset, but no longer because it would
compromise the channel it authorises — there is no such channel now. It is
because it authorises a *different* transport: a LAN peer that can rewrite the
PIN can lock a phone out of BLE, which is the recovery path.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-24 18:31:26 +02:00
Pierre Rouanet
593994514d docs: authority is noted not imposed, and update-over-WebRTC is a deferral
Two positions corrected after review, both of which the doc was arguing more
strongly than it should.

§9 no longer requires authority arbitration to land with this feature. The
premise it breaks is real and stays documented — `intents.rs`'s slots are
"single-writer in practice", and a pad plus a remote peer at 50 Hz interleave
into one slot rather than contending — but the section's job is now to make a
confusing robot explicable rather than to add a constraint on `robotd`. It also
records the cheap answer for when it is time: a single-writer token, which
removes the interleaving without §6's full priority ordering, and which priority
can be layered onto later.

§8 no longer reads as a rule. Update over WebRTC is wanted later, so the section
says what has to be true first rather than why it must not be. Two specific
things, both small: a client that reconnects and re-subscribes after `mediad`
restarts — which the protocol already supports, since progress is a
notification precisely so a reconnecting client keeps receiving it — and
`RobotRemoteSessionActive` learning to tell a bystander session from the one
that requested the update. That distinction is worth designing now rather than
retrofitting, because nothing sets the flag yet: the moment `mediad` reports
honestly, an update over WebRTC refuses itself and looks like a bug in the
update path.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-24 18:23:07 +02:00
Pierre Rouanet
aedddcbf8a docs: the WebRTC design, scoped to local signalling
architecture.md §5 states the requirement; this owns the mechanism. Local
signalling only: everything in it runs on the robot and works on a LAN with no
backend, because the remote case is defined in terms of the local one rather
than beside it.

The load-bearing choice is `webrtcsink` over `webrtcbin`. It brings a signalling
protocol, a session model and per-consumer encoder management, so what is left
to design is the control surface rather than the media plumbing — and that
protocol is what a remote bridge proxies, which is why the bridge is a relay and
not a translator. `run-signalling-server` (verified present in gst-plugins-rs
0.15.3, with host and port properties) means mediad runs the server in its own
process, so the separate gst-webrtc-signalling-server binary never has to be
built or shipped.

Four things it decides rather than defers:

  - Authorisation reuses `system.authenticate`. The signalling port is open on
    the LAN by necessity — loopback-only would force every session through a
    bridge — so the control channel serves that one method and refuses the rest
    until it passes, exactly as a BLE session does. Two schemes for two
    transports is how the newer one ends up weaker.
  - The control channel is a pipe to the JSON-RPC that already exists. btd is
    the precedent and three of its four files are transport-independent; the
    routing table should be lifted so the exhaustive match over proto::Call
    holds *per transport*, or WebRTC becomes the hole in it. Replies stay
    uncorrelated, so the pipe needs no per-method work.
  - Teleop carries sequence numbers. intents.rs takes last-writer-wins, which is
    correct over a unix socket and wrong over SCTP with maxRetransmits=0 — a
    twist from 80 ms ago can land after a fresher one and win. Transport
    property, so it belongs in mediad.
  - update.* mutations are refused, and the refusal says why: preflight already
    declines an update during a remote session, and restarting mediad drops the
    session the client is watching progress on.

And the thing this feature breaks. intents.rs says its slots are "single-writer
in practice"; a pad and a remote peer both driving at 50 Hz makes that false,
and the result is not a contest but two writers interleaving into one slot.
architecture.md §6 already calls for priority and handoff and the roadmap defers
it to M6 — WebRTC is what makes it due. That is the one part touching a file
robotd owns, and the part most worth arguing about before it is written.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-24 18:19:16 +02:00
Pierre Rouanet
c57d5cf003 Merge main into docs/robotd-design-reorg
Only `robotd-design.md` conflicted, in §2.4 Safety: this branch rewrote the
section, and main meanwhile removed the fall gate and the fall recovery, made
`limp_fall` the only answer to a fall, and shipped it on.

Both sides are kept. The section keeps this branch's corrections against the
code — non-finite refusal, the actuator range clamp, the deadman — and takes
main's newer facts on top: the verdict is published and gates nothing, and
limp-fall becomes §2.4.1 under the new numbering.

The rest of the page said the old thing in five more places, so it says the new
one instead: the `safety.apply` box and the `driving` line in §1.4, the
bring-up conditions in §3.3 (the "not fallen" gate is gone, and being down is
now called out as deliberately *not* a condition), "fall recovery" in §5.3, and
the safety test in §6. §7 gains the decision itself.

Assisted-by: Claude:claude-opus-5
2026-08-24 15:23:18 +02:00
apirrone
ca4865d9cd limp-fall on by default, and pose back to standing in 0.3 s
Validated on a robot: the sequence works with the defaults unchanged, so
turn it on for the fleet rather than leaving it opt-in. A mode every
board has to be edited individually to get is a mode most boards do not
have — and the whole point is that robots land soft.

The pose ramp drops 1.0 s -> 0.3 s, measured on the same robot. The
joints travel across the floor unloaded rather than lifting anything, so
the extra 700 ms was dead time between the landing and the stand-up.

Both are changes to the *built-in* defaults in params.rs, which is the
only lever that reaches an existing robot. `deploy/robotd.toml` is
fetched once at provisioning and never overwritten by an update, so a
commented line there is documentation: it tells a human what the default
is, it does not deliver it. The commented values are updated to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:49:27 +02:00
apirrone
19fa68d10b Remove the fall gate and fall recovery; limp-fall is the only fall mode
Three settings had "fall" in the name and did different things, two of
them shipped off and unused on every robot in the fleet. `fall_limp` and
`limp_fall` were a one-character swap apart. That was the confusion.

Gone: `fall_limp` (hold at limp gain once down, refuse
init/enable/skills until upright), `fall_recover` (limp 0.3 s, then the
standing net until upright), the `fall_gate` they fed, `Safety`'s
recovery bypass, `Controller::force_standing`, and `Limit::Fallen`.

Kept: the `fallen` verdict, which was never the confusing part. It is
tracked and published exactly as before — and it now gates *nothing*. A
fallen robot is enabled, init'd, driven and sent skills like an upright
one, which is what someone standing over a robot on the floor actually
needs, and it was already the shipped default.

That falls out of the removal rather than being bolted onto it, and it
is what makes the new mode honest: `Safety::apply` has no fall branch to
exempt, so limp-fall drops the gain by *asking*, through the same call
as every other tick. `fall_recover` needed `set_recovery` to bypass the
gate before it could stand the robot up, and a safety rule that recovery
has to switch off in order to work is not a safety rule.

`fall_recover` is not missed: limp-fall ends by handing back with the
twist at zero, so command magnitude selects the standing network, and
that is the stand-up. The standing net is also no longer reserved, so it
stays selectable the way the walking robot expects.

Net −207 lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:35:28 +02:00
apirrone
cfdb7e875a limp-fall: land soft, then stand up from a still robot
The standing policy is a good stand-up-er and a bad faller. From a still
robot, face down or face up, it gets back on its feet cleanly; out of a
dynamic fall it tries and fails and tries again — at walking gain,
against the floor — and the motors pay for every attempt.

`[safety] limp_fall` (off by default) takes the fall away from it. A new
detector in `duck_control::fall` sees the fall *start*: the existing fall
verdict is a position held for 200 ms, which by construction latches once
the robot is already down, so this one works on the rate instead.
Projected gravity rotates with the trunk, so `ġ = −ω × g` is exact and
comes straight from the gyro in the same 12-byte IMU block; extrapolated
over 300 ms it says where gravity is heading. It fires when the robot is
already tilted past ~26°, is still tipping over rather than recovering,
and the extrapolation lands past the fall threshold — debounced three
ticks, which is longer than a footfall impulse.

Then: limp at `gain_limp` following the joints down, wait for the gyro to
go quiet (or a 1.5 s cap), ramp back to the standing pose over a second,
hand back. With `fall_recover` also on it hands straight to the recovery
rise — that mode's own 0.3 s settle is what just happened, at length.

The tuning is the whole feature and it is asymmetric: a false positive is
a fall the robot *caused*, which is worse than the stiff landing it was
avoiding, so every default sits on the late side. All of it is
configurable, and the predictor's numbers are computed on every robot
whether or not the mode is on, so they can be tuned against a recording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:09:59 +02:00
Pierre Rouanet
90ae48eec0
Merge pull request #115 from pollen-robotics/updates-over-ble
Updates from a phone: trigger, watch, and go back over Bluetooth
2026-08-24 11:26:47 +02:00
apirrone
cc972c533f Kinematics & odometry: the robot learns where it is, from its own MJCF
Two new subcrates, absorbed from the prototype side:

- `kinematics`: FK parsed from the same robot_walk.xml the policies train
  against — update the MJCF and it still works. Compiled for the control
  loop: per-site flattened chains, indexed joints, zero allocation per
  query (~7-10x the prototype crate's site_pose). Hand-rolled quat/pose
  math pinned to MuJoCo's mj_kinematics at 1e-6 by fixture, cv2/sensor
  conventions and alpha sign pins carried over from the runtime shim.

- `odometry`: the Rhoban-derived contact estimator (sole-corner anchor,
  IMU re-projection), with the foot chains taken from the kinematics
  model instead of hand-transcribed segment tables, and each foot's
  chain evaluated once per tick instead of once per corner.

robotd ticks the estimator at the loop's own 50 Hz on the sample it
already read — no extra bus traffic, which is exactly the pressure the
spasms investigation taught this loop not to add — and publishes the
estimate as `odom` on robot.state (serde-default, no API bump). The
monitor shows x/y/yaw on its own header row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:47:44 +02:00
apirrone
bf3f9c81a2 tofd: ship it — package the unit and the binary, and provision the bus
The previous commit built the daemon but nothing carried it: both the
binary and the unit are explicit lists, so the release had neither and
`systemctl status tofd` found no unit. Now:

- All three packaging sites stage the `tofd` binary and `--include`
  tofd.service + its sysusers conf. postinstall enables and starts
  every packaged unit, so an `update apply` brings tofd up by itself;
  the xtask tripwires enforce the three lists staying in step.
- install.sh names tofd.service in the two hardcoded stop lists (the
  install path itself reads the release directory, so it needed
  nothing).
- setup-board.sh gains configure_tof(): the /dev/i2c-pihat udev rule,
  so the bus has a stable name across a kernel or overlay renumber.
  The bus, overlay and vendor kernel were already done by the audio
  section — the sensor shares the codec's i2c3 — and i2c-tools moves
  into that package list, since its postinst is what creates the `i2c`
  group tofd joins (and i2cdetect is the first thing anyone runs when
  a device on that bus goes quiet).
- architecture.md records tofd in the service table with why it is not
  in robotd (firmware upload, shared bus, mostly-absent sensor) and
  why it is not mediad's (a sensor on a bus, not a media pipeline).
  The cheatsheet documents the panel, the three absence states, and
  that stopping it is safe.

Verified: monitor survives tofd being SIGTERMed and recovers when it
returns, with no panic and a sentence saying which of the three
situations it is in — nothing depends on the unit, and robotd has no
reference to it at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:01:55 +02:00
Pierre Rouanet
4b93201426 docs: architecture.md opens with the whole system on one page
Nothing answered "how do the parts fit together" without reading four
design docs first. The service table in §1 assigns responsibilities but
draws no picture, and the sockets, the on-disk layout and the update
lifecycle were each documented where they are owned — correctly, and
uselessly for someone who does not yet know which page that is.

Adds an unnumbered overview ahead of §1: one diagram from the four ways in
(pad, phone, laptop, a release) down to the servos, the four claims that
explain the split, a service table with the socket each one answers on,
where state lives and what survives an update, and the path a change takes
from a push to a kept or reverted release.

Unnumbered on purpose — the numbered sections keep their numbers, so
nothing that cites architecture.md §1..§10 has to move.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-20 12:26:15 +02:00
Pierre Rouanet
0f9d0d7eee docs: robotd-design is organised by subsystem, and draws the bus
The page was ordered by the slices it was written for, so finding out how
anything works meant knowing which increment built it. The tick was
described twice, in §4.1 and §5.10; the two diagrams that orient a reader
were inside §3.1; the bring-up state machine was a §5.7.1.

Reordered into three tiers. §1 is the whole system on one screen — the bus,
the clients, the crate boundary, the tick, the five invariants. §2–§4 are
the reference detail, in the order data moves: bus, observations, policy,
safety, then the API, then what surrounds the loop. §5 onward is why it
looks like this, what is deferred and what is open. No reasoning was
dropped, only moved and deduplicated.

Adds what no page owned:

  - The bus topology. Sixteen devices on one UART, /dev/ttyS2 at 1 Mbps:
    the imu_to_dxl board at id 200, then 20-24 / 30-34 / 10-14.
  - Who owns the port, and that nothing enforces it. serialport sets
    TIOCEXCL, but robotd runs as root, so `robotd init` against a running
    daemon puts two writers on one bus. serial-getty@ttyS2 is the other
    claimant, and `fuser -v /dev/ttyS2` is the command that answers it.
  - The slow path: registers 144-146 once a second, on the same clock as
    the achieved-rate window, and why it cannot fold into the tick's read.

Corrects what had drifted from the code:

  - MissedTickBehavior is Skip, not Delay, and the reason Delay is wrong.
  - RobotIo is six methods plus two defaulted, not two.
  - Net selection is the priority chain, not walk-or-stand on |twist|.
  - Fall -> limp is a mode that ships off; the fall verdict is always
    reported. The page called it unconditional.
  - The wheeled configuration is a params preset, not a dropped variant.
  - Tunables pointed at §8, which was the testing section.

Section numbers moved, so the five call sites that cite them move with
them. robotd's own module doc claimed no observations, no policy and no
intents, in a file that has all three.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-20 11:31:12 +02:00
Pierre Rouanet
0a3c183f15 btd: going back to a working release is reachable from the phone
update.rollback and update.select were refused, on the reasoning that the
engine reverts a bad release on its own. It does — the one that fails its health
gate. That is not the case an owner reaches for a phone about, which is a
release that installs, passes its gate and then behaves worse: a policy that
walks unsteadily rather than not at all, a pad that stops reconnecting. Nothing
reverts that but a person, and the person is holding a phone and has no ssh.

Both move the robot to a release that has already run on this board, download
nothing, and are gated and auto-reverted like any other transition. update.apply
was already routed and is the more consequential of the three, so this widens
what a peer in radio range can do by close to nothing.

Rollback is the undo, one tap and no arguments. Select is the same authority
plus a version number, and it is what update.listInstalled — already routed — is
for: being able to show the releases on a board without being able to choose one
would be the odd half.

update.pin stays refused, and it is the interesting one: a wrong select is one
release away from being undone and the robot says which release it is on,
whereas a robot pinned by a mistap refuses every later update and reports itself
as up to date. That is the one failure here that looks like correct behaviour.
update.resetToGolden stays refused because it discards releases; neither of
these does.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-19 16:21:14 +02:00
Pierre Rouanet
8e8fc54a42 btd: a long call no longer silences the rest of the session
`Pool` held one connection per service per session, and both daemons behind it
serve one connection one request at a time. So calls queued behind whatever was
slowest on the queue, and the two orderings a phone app reaches for first were
broken by it:

  update.subscribe, then update.apply — `stream_progress` owns its connection
  until the peer goes away and never reads another request, so the apply was
  written into a socket nobody was reading. It never ran, never replied and
  never errored: an update the owner asked for that the robot silently did not
  perform.

  update.apply, then update.status — the status line waited in a socket
  `updaterd` would not read for minutes. `updaterd` goes to some trouble to
  answer a status poll during an update (a cached snapshot with the live phase
  patched in) and all of it was wasted.

Calls are now grouped by how long they hold a connection — Prompt, Slow,
Operation, Stream — and each group gets its own connection. The lane is decided
in `route.rs` beside the permission and the service, so the exhaustive match
makes a new method's author choose one; at most four sockets per service per
session, and in practice two.

The alternative of a connection per call would need btd to know when a call
ended, which needs it to parse replies. It deliberately never does.

Both failures are covered by tests that fail without this: a fake with
updaterd's connection model, reporting which connection each line arrived on.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-19 16:20:51 +02:00
Pierre Rouanet
1cd7768757 btd: the advertisement carries the robot's IPv4 address
`duck-btctl scan` connects to nothing, which is what makes it the command
to reach for when a robot is unreachable — and it is why a listing could
only report what an advertisement carried. So the question a listing is
most often read to answer, *where do I ssh?*, had no answer in it: the
address is in `net.status`, and reading that costs a connection, a bond
and the PIN, per robot.

Four bytes of IPv4 now go out in a manufacturer-data field under company
id `0xFFFF`, the id the SIG reserves for internal use. `btd::adv` holds
the layout and both halves use it, so the encoder on the robot and the
decoder on the laptop cannot disagree. The budget is the reason it is
four bytes and no more: flags (3), the 128-bit service UUID (18) and this
field (8) spend 29 of the 31 bytes a legacy advertisement holds, which is
also why the SSID is not here and cannot be — it is up to 32 bytes on its
own, and stays a `wifi status` question.

A robot with no address advertises `0.0.0.0` rather than dropping the
field, so `scan` tells three states apart: an address, a robot on no
network, and a robot on a release from before this existed, which
broadcasts no field. The last two want different next moves.

Three things this had to get right:

- `configd` failing to answer keeps the last known address rather than
  clearing it. Clearing it would deregister and re-register the
  advertisement every tick for as long as a `configd` restart lasted,
  with a client watching the address blink;
- BlueZ refusing an advertisement carrying the field is retried without
  it. The arithmetic says it fits, but the byte that overflows is the
  controller's to count — and an advertisement with no address is a robot
  someone can still reach, where a refused one is a robot gone dark;
- `--name` pins the name, not the advertisement. The reconcile loop used
  to be skipped entirely under a pinned name, which was the same thing
  back when the name was all the advertisement carried; a pinned name
  does not pin a DHCP lease.

`0xFFFF` is open to anyone, so the field is never an identity check: it
is read only from a device that also advertised the service UUID.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-19 15:18:54 +02:00
Pierre Rouanet
69e8b98147
Merge pull request #113 from pollen-robotics/provision-name-flag
provision: --name gives the board its name, instead of DUCK_NAME
2026-08-19 14:57:42 +02:00
Pierre Rouanet
b471cbc752 provision: --name gives the board its name, instead of DUCK_NAME
`provision-board.sh` never forwarded `DUCK_NAME`, so the only way to name a
board while provisioning it was to run `provision.sh` on the board by hand.
Through the wrapper the name silently did nothing, and the fix for that was
either forwarding one more environment variable or making the name what it
actually is: one decision per board, typed at the keyboard.

So `--name NAME` on both, and `DUCK_NAME` is gone. The other knobs are
environment variables because they are passed on — `install.sh` reads the same
names, so a pinned tag is one decision for a whole bring-up — while a name goes
no further than `robotctl system set-name` at the end of phase 2. It is also the
only one that is per-board rather than per-session, and an exported name is
exactly the kind of thing that is still set when the next board is provisioned.

The state file that carries the name across the reboot is now written quoted.
It is read by sourcing it, and a name is free text: `DUCK_NAME=Ducky Two` came
back as an assignment plus an attempt to run `Two`, which under `set -eu` ended
phase 2 on its first line — so naming a robot after two words broke
provisioning rather than just the name. Every value is quoted, not only the
name, and the two keys that are not environment knobs are `PROVISION_*` so that
sourcing the file cannot set something an operator could also have exported.
The name is escaped again on the way over ssh, which hands the command to a
second shell.

Assisted-by: Claude:claude-opus-5[1m] shellcheck
2026-08-19 14:52:39 +02:00