setup-board: pause btd without imposing Privacy = device
microduck daemon
The software that runs on the robot, and the machinery that ships it there.
robotctl is how you talk to a robot. It runs on the robot itself.
Every command describes itself, so you can explore rather than read:
robotctl --help
robotctl update --help
Tab completion comes with the install — press Tab for the commands a release actually has. For a shell it did not set up:
eval "$(robotctl completions zsh)"
Is it alive
robotctl health
robot healthy
loop 50.1 of 50.0 Hz · 2834 ticks · 0 missed · last 13 ms ago
bus ok
imu ready
battery 7.62 V (64%)
motors 41 °C max (left_knee) · 36 °C mean
cpu 52 °C
software
updaterd 0.5.0 (rev abc1234)
robotd 0.5.0 (rev abc1234)
configd 0.5.0 (rev abc1234)
daemon 0.5.0 installed
last update 0.4.1 → 0.5.0: applied
units
updaterd active · 0.5.0 (rev abc1234)
robotd active · 0.5.0 (rev abc1234)
configd active · 0.5.0 (rev abc1234)
btd active · 0.5.0 (rev abc1234)
padd active · 0.5.0 (rev abc1234)
The two software blocks answer different questions. software is what each daemon that serves a
socket says about itself, and what is installed on the board; units is what systemd says about
every unit a release manages — including btd and padd, which answer no version query and can
only be reported from outside — with the release each process was actually launched from. That second one
is the question after an update: a daemon still running the old binary shows a release older than
the installed one, and the report names the restart that fixes it.
Drive it
Switch the pad on and drive — padd runs from boot, waits for a pad and drives whatever connects.
Pairing is once per pad and lives in
docs/robot/pair-a-gamepad.md: sudo robotctl pad pair with the pad
in pairing mode, plus what to do when it will not bond.
robotctl pad status
pad Xbox Wireless Controller 78:86:2E:BB:13:28 connected
padd active — driving whatever pad connects
padd reads the pad and sends intents over the socket. It has no privileged access — it is an
ordinary client, sending exactly what the app and the SDK will send, which is why pairing is
configd's job rather than its own.
Driving from your laptop works too, with the socket forwarded — pad in your hands, robot on the bench, nothing installed. Stop the one on the robot first, or two processes fight over the sticks:
sudo systemctl stop padd
ssh -L /tmp/robotd.sock:/run/robotd.sock radxa@192.168.1.42
Leave that open, and in another terminal from this clone:
cargo run -p padd -- --socket /tmp/robotd.sock
The controls:
| Start | enable / disable the policy — nothing moves until this is on. On a limp robot this is also what powers the joints: torque on, two seconds to the home pose, then it drives |
| Y / triangle | switch between driving the body and posing the head |
| B / circle | stop |
| left stick | body: forward/back and strafe · head: neck pitch and roll |
| right stick | body: turn · head: head pitch and yaw |
Two things worth knowing before the robot surprises you. Sticks drive the body or the head,
never both, so switching to head mode zeroes the body velocity rather than leaving it walking.
And if the pad disconnects, padd sends nothing at all — robotd's deadman stops the robot on
its own, which is the wanted behaviour and the reason padd does not invent a zero command.
The first Start after power-on moves the robot: the joints go from wherever they are resting to the home pose over two seconds. Hold it, or have it on its stand.
The same two steps by hand, for when there is no pad in the room:
sudo robotctl robot init
sudo robotctl robot relax --yes
init powers the joints and ramps to the home pose; relax cuts power, and the robot collapses
if nothing is holding it. That is the only way back to limp short of pulling the plug — pressing Start
again stops the policy but keeps the robot standing.
A robot the IMU already considers fallen refuses both Start and robot init: the fall gate holds a
fallen robot limp on purpose. Stand it up by hand first.
Speeds are conservative by default. --max-linear (m/s), --max-angular (rad/s) and
--max-head (radians) raise them; --deadzone is there because analogue sticks rarely rest at
exactly zero and the robot creeps without it. The unit runs with the defaults, so to use those
flags on the robot, stop it and run the binary yourself:
sudo systemctl stop padd
sudo -u padd /opt/robot/daemon/current/bin/padd --max-linear 0.25
systemctl start padd puts the default back.
Watch what it is doing
robotctl monitor
The one window into the control loop. It shows what a client asked for beside what was actually
applied, and names the reason when they differ — safety clamps things constantly, and "the stick
is forward and the robot is still" is unreadable without that. A limit is spelled out rather than
named: deadman — no intent arrived recently, velocity zeroed.
Also on the frame: every joint measured against what it was commanded, the IMU's projected
gravity and the fall verdict drawn from it, and the achieved loop rate as a trace so a stutter
that has already recovered is still visible. The bottom border names the policy that is loaded,
because walk is a mode two releases with different gaits both report — and "which network is
this?" is the first question when comparing them.
p opens the gamepad's raw input: every evdev report from the pad padd is driving from, the
sticks and buttons as the kernel delivered them, and the gaps between reports as a trace. That last
part is the reason it exists — padd resends the last stick value at 50 Hz, so a radio that has
stopped delivering still looks like a live driver in the asked column above it, and the robot walks
on a command nobody is giving. Reading it is in
pair a gamepad.
q quits, ↑/↓ scroll the joint list, u switches the angles between degrees and radians.
Angles are degrees on screen — joints, head and the yaw rate. Redirected or piped it prints one
line per tick instead, so > run.log and | grep FALLEN behave, and those numbers stay radians
whatever the screen is set to. The joint vectors are in --json:
robotctl monitor --json --hz 50 > run.jsonl
Run your own policy
You do not need to cut a release to try a network. Point robotd at your own .onnx on the
board, in /etc/robot/robotd.toml:
[policy]
walk = "/home/radxa/my_walking.onnx"
stand = "/home/radxa/my_stand.onnx"
sudo systemctl restart robotd
Your paths survive updates. Delete the lines to go back to the policy the release ships.
A policy that could not be loaded reports unhealthy — robotctl health and the bottom of
monitor both name the reason. The shape a policy has to have, and what else is checked at
load, are in docs/design/robotd-design.md §5.3.
Keep it up to date
What each daemon is running, and what is installed:
robotctl version
Install the latest release:
sudo robotctl update apply daemon
Go back if it misbehaves:
sudo robotctl update rollback daemon
daemon covers every binary. On a dev board this installs the latest stable release, which is
usually a downgrade — use --ref below instead.
A release candidate — published, not yet promoted — is flagged as a prerelease, and a plain apply
skips those so no robot drifts onto a build nobody has validated. Ask for one by name:
sudo robotctl update apply --staging daemon
To pick a candidate rather than the newest one:
sudo robotctl update apply --staging --version 0.5.1 daemon
The flag applies to that one command and leaves nothing switched on, so the next apply is back
on stable. This is what a canary robot runs before a promotion.
Put your branch on the robot
Make sure the board has been through the dev install first — a board that has not will refuse branch builds.
Push your branch, then wait for CI to build it:
gh run list --branch my-branch
Once it is green, on the robot:
sudo robotctl update apply --ref my-branch daemon
robotctl version
Go back:
sudo robotctl update rollback daemon
Every push needs the apply again.
Or skip CI entirely
From a clone of this repo, with the board reachable over ssh:
scripts/dev-push.sh radxa@<board>
It builds here, signs with the dev key, copies the release to the board and applies it — same
verification, same health gate, same auto-rollback, about a minute instead of a push and a CI run.
Setup and the one-time first push are in dev-push.md.
Cut a release
Releases are built and signed in CI, never on a laptop. Two tags: the pre-release goes to a canary robot, and the release promotes exactly those bytes.
Bump version under [workspace.package] in Cargo.toml first — 0.5.0 to 0.5.1 here. xtask package refuses a tag that disagrees with it, which is what stops a robot reporting a version it
is not running. Then refresh the lockfile:
cargo update --workspace
Merge that, then from main:
git tag daemon-staging-v0.5.1 && git push --tags
CI builds it, signs it, verifies it through the real update engine and publishes it as a prerelease. Watch it:
gh run list --workflow release
Once it is green, on the canary robot:
sudo robotctl update apply --staging daemon
Drive it. When it holds up, from main again:
git tag daemon-v0.5.1 && git push --tags
That promotes the staging build rather than rebuilding it — the same bytes, re-signed with the key customer robots trust. Creating the release in the GitHub UI instead of pushing the tag does the same thing.
A daemon-v tag with no staging build behind it is allowed and builds directly. Both are signed
the same way, so the difference is validation rather than authenticity, and the release notes say
which one happened. Key custody and the promote workflow — including min_supported, for forcing
robots off a bad release — are in CONTRIBUTING.md and
docs/project/ci-setup.md.
Where next
Going further with a robot
| Cheat sheet | Every robotctl command: wifi, updates, rollback, pinning, logs. |
| Dev board cheat sheet | Branch builds, release candidates, and the restart traps after an update. |
duck-btctl cheat sheet |
Every duck-btctl command: the robot over Bluetooth, from a laptop, with no network. |
| Setting up a dev board | From nothing, and the fix when --ref is refused. |
| How it works | The control loop, the update engine, the service split, the BLE path. |
Contributing
| CONTRIBUTING.md | Building, testing, repo layout, conventions, releasing. |
| Roadmap | What works today, what is next, and what we are deliberately not doing. |
| Open problems | Records of what has gone wrong and what would close it. |