66 Commits

Author SHA1 Message Date
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
Pierre Rouanet
82cff86702
Merge pull request #111 from pollen-robotics/a-missing-route-refuses-a-version-does-not
A missing route refuses; a version difference does not
2026-08-19 14:48:09 +02:00
Pierre Rouanet
90de83a5e4 updater: a degraded robot is a hardware fault, not a failed release
A branch build was installed on a bench board, paired a gamepad, and two boots later the board was
running the stable release again — silently. Every command afterwards ran against code nobody had
asked for, and the symptom presented as a feature that did not work.

Two things combined. `hooks/postinstall` restarts `updaterd`, the process running the apply, so the
apply never reached `boot_counter.confirm` and left a trial armed that nothing would confirm. And
`recover_on_start` reverted on the boot counter alone, without ever asking the robot.

The second is the one fixed here. The budget still decides *when* to ask; the robot now decides
whether to revert, using the same three-way question `health_gate` has always asked a few hundred
lines away:

  - healthy — the release works. Commit it.
  - degraded — the robot is not working for a reason a rollback cannot fix: no servo power, a loose
    bus, absent hardware. §4 already says the boot net cannot fix hardware because a robotd with no
    servo power fails identically on golden; the same holds here. Reverting hides a hardware fault
    behind a software change and reverts the next release too.
  - anything else — revert, as before. Unhealthy, unreachable, or answering in a shape this updaterd
    cannot read is what this budget exists for.

Three existing tests asserted a revert while handing recovery a *healthy* robot, which was incidental
to what each was testing — the escalation chain, trial independence, and Stuck-when-there-is-nowhere-
to-revert. They now use an unhealthy robot, so each still tests its own subject rather than passing
for the wrong reason. Two new tests cover healthy and degraded being committed, over more restarts
than the budget allows.

Not fixed here: the apply that arms a trial it cannot confirm. That wants its own reasoning about
where `confirm` belongs relative to the restarts.

Assisted-by: Claude:claude-opus-5
2026-08-19 14:38:19 +02:00
Pierre Rouanet
28298be2f8 updaterd: a missing route refuses, a version difference does not
`hello` refused on an exact `API_VERSION` `!=`, and `hello` precedes every
`robotctl` command — so one differing digit took away every command at once,
including `update apply`, which is how a skew ends, and `version`, which is how
it gets diagnosed. The premise was right (a bump promises nothing) and the
conclusion did not follow: what breaks a mismatched peer is a route it cannot
reach or a parameter shape that moved, and both of those already refuse
themselves, by name, on the one call that cannot be served.

So the difference is reported and served: `updaterd` logs the pair of versions,
`HelloResult::api_version` carries it to the client, and `robotctl` prints one
warning line per run naming which of the three daemons disagreed. `robotd`,
`configd` and `padd` never gated on the number, and `duck-btctl` already warned
rather than refusing, so this was the last locked door.

What made the gate feel load-bearing was silence, not disagreement: `serde`
ignores what it does not know, so v7's `ApplyOptions::from_dir` sent to a v6
daemon would have installed from the *configured* source while the operator
believed they were sideloading a directory. Every params type now denies unknown
fields, which turns that into an `INVALID_PARAMS` naming the member, and a test
over `every_call()` fails if a params type is added without it. It reaches
forward only — a daemon built before this still ignores what it does not know —
so an `API_VERSION` bump is still worth making.

No wire shape changed, so `API_VERSION` stays at 8. A board only gets this once
it installs a release containing it; until then, `systemctl restart updaterd`
puts the resident daemon back on the installed release and clears the skew.

Assisted-by: Claude:claude-opus-5
2026-08-19 14:19:13 +02:00
Pierre Rouanet
59cb88b6a7
Merge pull request #102 from pollen-robotics/a-version-warning-not-a-locked-door
duck-btctl: a version mismatch is a warning, not a locked door
2026-08-18 16:11:07 +02:00
Pierre Rouanet
a25fb8fe01 duck-btctl: a version mismatch is a warning, not a locked door
`duck-btctl` read the robot's API version and refused to send anything if
it differed from its own. Against a v7 robot from a v8 laptop that meant
every command failed with "install matching versions", including the ones
that would have worked.

They would all have worked. This tool never sends `hello`; `configd`
checks no version on `net.*` or `system.*`; `updaterd` requires no
handshake before `update.status`. Nothing across the link agreed with the
refusal, so it blocked calls the robot was willing to answer — v7 to v8
was `pad.input`, a namespace `padd` serves on a socket of its own that
never crosses BLE.

It was also the wrong moment to be strict. BLE is the transport for a
robot with no network, and `wifi connect` is how that robot gets one. A
board with a stale release and no wifi could not be given wifi by the
tool that exists for exactly that, because the tool refused over the
staleness it was being used to fix.

`API_VERSION` is an agreement between the binaries on one board, which
`updaterd`'s `!=` on `hello` enforces. A laptop is not one of those
binaries and is routinely a release ahead, being the machine that builds
them. So the read now reports rather than gates: it warns that the two
ends were not built together and carries on. A method whose params really
did change comes back as a JSON-RPC error naming it, printed and carried
into the exit status.

`app-path-design.md` §3 says the same to the app, which would otherwise
inherit the refusal.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-18 12:22:42 +02:00
Pierre Rouanet
a791fc97a0 btd: the robot's name goes on the adapter too, not just the advertisement
A robot has two names. The advertisement carries a Local Name, and the
adapter separately serves a GAP Device Name (0x2A00) that BlueZ takes
from Adapter.Alias and defaults to the hostname. `btd` set the first and
never the second, so a renamed board advertised `duck-5b21` and answered
`radxa-zero3` to anyone who read the characteristic — which is what a
central does on connecting.

BlueZ then caches that answer over the advertised name, so on Linux one
robot scans as `duck-5b21` before first contact and `radxa-zero3` after
it, and `duck-btctl --name duck-5b21` stops finding a robot sitting on
the bench. Two scans a minute apart disagreed about what was in the room.
CoreBluetooth keeps both and reports them joined; a phone's Bluetooth
settings shows the GAP name, which is the case that matters most and the
one nothing in this tree could see.

`advertise` now sets the alias alongside the advertisement, so no path
can publish a name without it: `reconcile_name` re-advertises on every
rename and comes through there to do it. The write is skipped when the
alias already says the right thing, and a failure is logged rather than
propagated — being visible is worth more than being correctly named.

A client that cached the old name keeps it until it is forgotten, which
`docs/robot/duck-btctl.md` now says how to do.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-18 12:19:21 +02:00
Pierre Rouanet
8c16186984 fix(dev-push): sideload where updaterd can actually read it
`updaterd.service` sets `PrivateTmp=yes`, which gives the unit its own
`/tmp` *and* its own `/var/tmp`. `scripts/dev-push.sh` copied the release
to `/var/tmp/duck-sideload`, so the files landed in the pair the shell
sees and the daemon read the pair the namespace gave it. Every push
failed at

    error: network error: no manifest for version 0.5.1-dev.local.… in
    /var/tmp/duck-sideload

against a directory whose `ls` lists that exact manifest, its signature
and the artifact — a sentence with nothing in it to act on, which is why
it cost an afternoon rather than a minute. The tests never caught it:
they point `LocalDir` at a fixture root, where no namespace applies.

Three changes, one per way this could recur:

- The push now sideloads from the board user's home, resolved on the
  board. It keeps the property `/var/tmp` was chosen for — writable by
  the ssh user, so no sudo to copy into it — and is outside the unit's
  namespace.
- Preflight gains `Check::SideloadDir`: a `--from` directory this process
  cannot see fails before any lookup, and when the path is under `/tmp`
  or `/var/tmp` the failure names `PrivateTmp=yes` and says where to put
  the release instead. A board that does not privatise `/var/tmp` passes
  it without noticing it exists, and `updaterd install --from` — which
  runs as a CLI, outside the namespace — is unaffected.
- `xtask/tests/sideload.rs` holds the two files against each other: the
  script may not name a path the unit hides, and while it sideloads from
  a home directory the unit may not set `ProtectHome=`. Neither file can
  check that alone, and a third test fails if both halves stop applying
  so the file cannot go quietly vacuous.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-17 15:38:57 +02:00
Pierre Rouanet
b64872b988 The laptop client is duck-btctl, not btctl
It is the one artifact of this repo that lands on a developer's PATH, and
`btctl` claims a name in that namespace that says nothing about what it
controls. It sits next to `bluetoothctl`, `btmgmt` and `btmon` — all of which
drive a Bluetooth stack rather than a robot — and a year from now, in
`~/.cargo/bin`, nothing distinguishes it from one of them.

`duck-btctl` says whose robot, matches the prefix the non-robot-local crates
already carry (`duck-control`, `duck-ipc-proto`), and is what `duck-<Tab>`
finds when the name has been forgotten. `robotctl` and the daemons keep their
bare names: they live on the robot's filesystem, where the context is not in
doubt.

`--version` is part of this. It answered `btd 0.5.1` — clap takes the name from
the crate unless told otherwise, so the laptop client reported itself as the
daemon it is an example of.

Anyone who installed the old name keeps a stale binary that no longer tracks
the branch, so the page says to remove it.

Assisted-by: Claude:claude-opus-5
2026-08-17 14:46:42 +02:00
Pierre Rouanet
daebac00bb Say so when the staging channel has nothing newer than the board
`apply daemon --staging` on a board running stable 0.4.0 installed 0.2.0. Not a
resolution bug: 0.2.0 is the newest `daemon-staging-v*` release that was ever
published, because 0.3.0 and 0.4.0 were promoted straight to stable and a
straight-to-stable release publishes no candidate. The staging scan answered
honestly and the board went backwards two releases.

Everything downstream then did its job. The artifact verified, the swap
happened, `configd` — which 0.2.0 predates — failed with 203/EXEC, its failed
restart failed the update, and the engine reverted to 0.4.0 cleanly. What the
operator got was a systemd error code and a rollback, with nothing naming the
cause. The orphan check added in c7cd15c will name the stale *unit* once it
ships, and that is the right refusal for a downgrade someone means; it cannot
name a stale *channel*, which is what this was.

So the resolved candidate is compared against what is installed, for bare
`Staging` only, and the refusal says the channel is behind and prints the
command that installs the candidate anyway. `StagingExact` stays unguarded:
naming a candidate is how the one a board just rolled back from gets
reinstalled, and it is the way past this refusal, so guarding it would leave a
board that can neither install the candidate nor be told why.

Deliberately not `WouldDowngrade`. That one claims a mirror may have gone
backwards and invites you to distrust the source; here the source is fine.
Reading "refusing to downgrade" is what sent the first person who hit this
looking for a broken mirror.

Assisted-by: Claude:claude-opus-5
2026-08-17 12:46:58 +02:00
Pierre Rouanet
7f5af83afe Merge main into robot-identity
btd's bring-up now retries in place rather than exiting (#67), so the name
reconcile moves inside it: it runs alongside the adapter watch, and losing the
radio drops both along with the advertisement handle. Each bring-up re-asks
configd, so a rename made while Bluetooth was down is picked up when it returns.

Two claims elsewhere in the docs said the serial still had to be generated and
printed at manufacture alongside the PIN. It does not — and updater-design.md
§5.6 had already chosen /proc/device-tree/serial-number as the update log's
stable device ID, for the same reasons, which this now cites rather than
presenting as new.

Assisted-by: Claude:claude-opus-5[1m]
2026-08-11 19:20:27 +02:00
Pierre Rouanet
8e497c4ed0 An update must not restart the recovery net's oneshot
`robot-boot-check.service` was in `on_apply`'s restart set:

    units_to_restart = ["configd", "padd", "robot-boot-check", "robotd"]

It asks whether the release that booted came up and hands over to `robot-rescue` if
not, so an update restarting it points a rollback check at daemons that are
legitimately mid-restart — and `robot-rescue` can answer by swapping to golden and
rebooting. Mid-update.

`hooks/postinstall` already declines to `enable --now` it, and says exactly why:
"`enable --now` on it would run a rollback check in the middle of the update that
installed it, with daemons legitimately mid-restart". The engine then read every
`*.service` and restarted it a moment later anyway. Two places, one rule, one unit,
and they disagreed.

What kept it harmless is a guard for a different caller: the script declines when
uptime is over ten minutes, and its comment names `postinstall` as the reason that
guard exists. Inside the first ten minutes of a boot — a dev board, or `auto_apply`
polling sixty seconds after `updaterd` starts — the guard does not apply and the check
evaluates its members while two of them are restarting. It needs `failed` or three
restarts to act, so it would usually decline; "usually" is not the property wanted from
the thing that reboots the robot.

Keyed on `[Install]` rather than on a name in `NEVER_RESTART`. A unit without one is
triggered by something else, so its lifecycle is not the update's to drive; and stating
it as the rule makes `postinstall` and the engine agree by construction rather than by
both being remembered. An unreadable unit counts as one to restart, because that is
more likely a permissions problem than a triggerless unit, and failing loudly beats
skipping quietly.

Demonstrated rather than argued: the fixture now ships a unit shaped like the real one,
recording each time it runs, and the harness asserts it did not. With the rule disabled
that assertion fails, printing the `ran` the unit left behind.

`install-path-gap.md` is retitled and closed. Its own body has said the gap is shut
since the artifact-install test landed, while its title and revision note still said
otherwise. `restart-order.md` gains the rule and stops calling five units the whole
set.

Assisted-by: Claude:claude-opus-5
2026-08-11 19:03:38 +02:00
Pierre Rouanet
2a3be0a259
Merge pull request #59 from pollen-robotics/dev-push-from-laptop
Build on a laptop, install on a board: `apply --from` and dev-push.sh
2026-08-11 18:03:30 +02:00
Pierre Rouanet
aae1f8bd84 Merge main, and make dev-push a full packaging site
Five conflicts, and one of them was not textual.

`ci.yml`: the lint list gained `robot-rescue` on main and `dev-push.sh` here. Both.

`API_VERSION`: main rewrote this comment to state the rule — a bump promises nothing
in either direction, and `updaterd` refuses on an exact `!=` deliberately rather
than pending (#77). That rule stands as written; the bump to 7 becomes an instance of
it, so `from_dir`'s reason is recorded under it rather than as a competing paragraph.

`xtask`: two changes to the same assertion, both wanted. Main skips a unit that execs
out of the *base* — the boot recovery net does, so that a broken release cannot break
its own rescue. This branch reads staged names by basename rather than matching a
literal `release/<exec> staged/`, because `dev-push.sh` builds in one of two
directories depending on the toolchain and names the source through a variable. Kept
the skip, then the basename parse.

`artifact.rs`: main's test name over this branch's `PACKAGING_SITES`, and the one
remaining `PACKAGING_WORKFLOWS` — in the recovery-net test main added — retargeted at
the renamed constant. It did not compile otherwise.

The substantive one: **`dev-push.sh` never got the four recovery-net `--include`
lines** that #74 added to both workflows, so a laptop build shipped a release with no
`robot-rescue`, no `robot-boot-check` and neither of its units. Silent until a board
needed the recovery it did not have. That is exactly what renaming the constant to
`PACKAGING_SITES` is for, and with the last usage retargeted the test says so; the
three lists are now identical.

Workspace suite, clippy, fmt and both shell lint steps green.

Assisted-by: Claude:claude-opus-5
2026-08-11 17:58:27 +02:00
Pierre Rouanet
827f539819
Merge pull request #80 from pollen-robotics/apply-repairs-skew-rather-than-reporting-inert
apply stops being inert when a daemon is not running what is installed
2026-08-11 17:57:37 +02:00
Pierre Rouanet
1f8b76d21b apply stops being inert when a daemon is not running what is installed
`Engine::apply` returned `AlreadyCurrent` on the installed version alone, which
is the right answer to the wrong question in one case: the release is installed
and a daemon is serving from a different one. That is what an operator running
`apply` is usually trying to fix, and "already current" told them there was
nothing to fix — the shape of failure that cost an afternoon and got written down
in `install-path-gap.md` section 4.

`reconcile` closed most of it at every `updaterd` start, and deliberately left one
skew alone: `updaterd`'s own, because a self-restart loop in the process that owns
recovery has no way out. So the one skew nothing repairs is reachable only through
the command that reported nothing to do.

`reconcile::stale_units` is that module's reading without the acting, and `apply`
and `select` call it on their already-current paths. Stale units are named in the
outcome and scheduled through the same `systemd-run` timer an update uses for its
deferred pair. Not the loop the startup check guards against: it fires once, on a
request, and from outside the cgroup being restarted.

`restarts_owed` is now the only thing that decides which units an outcome owes,
pure and tested per outcome, because the three cases — a fixed pair, a computed
list, and nothing at all — were previously one `matches!`. `schedule_deferred_
restarts` takes the units rather than reading the constant.

`apply --force`, proposed alongside this and never built, is dropped rather than
deferred: its whole job was re-running the restart on an already-current release,
which is now what happens when something is actually stale.

`stale` is additive on the wire and `serde(default)`: an older `updaterd` reports
none because it did not look, and `ApplyResult` does not deny unknown fields, so an
older client still decodes the outcome of an update it just performed. No
`API_VERSION` bump, and no install ordering to respect.

Assisted-by: Claude:claude-opus-5
2026-08-11 17:53:14 +02:00