microduck/docs/robot/install-dev.md
Steve Nguyen 36b878e84f setup-board: pause btd without imposing Privacy = device
`--weird-ble` does two things at once, and on 50:37:CD:16:1D:90 that
combination cannot be made to work. Measured there on 2026-08-19, one
variable at a time, daemon 0.6.0 throughout:

    Privacy   btd paused   result
    off       no           pairing dies ~800us after Encryption Change,
                           Remote User Terminated (0x13)
    off       YES          bonds, held 45/45 samples, real input, driving
    device    yes          bonds, then flaps: 46x PIN or Key Missing (0x06)

So the two halves fix different faults and are not a package:

  - btd advertising breaks a NEW bond. Pausing it for the pairing window
    fixes that — and it is what makes the aic8800 driver build irrelevant.
    Every earlier failure blamed on the driver had btd advertising, which
    was the uncontrolled variable; the same board bonds and holds on the
    stock 5.0 build once btd is paused.
  - Privacy = device breaks RECONNECTION on a board that does not need it.
    A clean bond then flaps with PIN or Key Missing, on either driver
    build, whether or not the bond was made under device. On such a board
    the flag is not merely unnecessary but harmful, and it fails in a worse
    way than not pairing at all, because it looks like it worked.

Hence `--pause-btd-on-pair` for the first half alone. `--weird-ble` still
implies it, so nothing provisioned before this behaves differently.

The marker is now written by `write_pause_marker`, called from both paths,
because only one of them touches Privacy — which is the whole distinction.
Its path is unchanged: robotctl reads that exact string, and renaming it
would make every board already carrying one stop pausing btd with nothing
to say why. The name is now narrower than its meaning, and says so.

`report` names both settings rather than only Privacy, because the pair of
them is what says which of the three configurations a board is in — and the
advice for a pad that will not bond depends on which half is missing. The
old line sent everyone to --weird-ble, which is the wrong answer for a
board that flaps.

install-dev.md drops `--weird-ble` from the recommended command in favour
of the pause, and gains a table mapping the failure you actually see to the
flag that board wants — including the flapping row, which is the one that
sends people chasing the wrong thing. pair-a-gamepad.md now leads with the
pause too.

Verified: the two functions exercised against fixtures over six cases
(neither flag, pause alone, pause over an existing Privacy = off, weird-ble
from unset, weird-ble correcting off, weird-ble with no [General]) — 14
assertions, and in particular that the pause never rewrites Privacy. Then
on hardware: with the marker removed first so the write path ran,
`DUCK_PAUSE_BTD=1` wrote the marker mode 644, printed "left Privacy alone",
and left main.conf byte-identical to a pre-run backup. board-test.sh gains
the pause-only case; note CI lints that script but does not run it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 11:10:49 +02:00

12 KiB

Installing on a dev board

Getting a board from nothing to a robot you can push branches to.

A dev board trusts the team dev key, so it will install anything anyone on the team builds. A customer robot is set up differently and deliberately refuses those builds — everything here assumes a dev board, never a customer robot.

Nothing is relaxed for a dev build: same signature and hash verification, same health gate, same auto-rollback. The only difference is which key signed it, and that is what keeps these builds off customer robots — they refuse a dev key twice over. allow_dev_keys = false, and a trusted key only counts as a dev key if its filename ends .dev.pub. Both halves of the setup below exist to flip exactly that.

Flash the board

Use the Armbian imager. Pick Radxa Zero 3, then Armbian 26.2.1 Minimal.

Before writing, fill in the imager's profile — wifi network and password, and the username and password you want. Doing it there saves a serial console later: the board joins your network on first boot and is reachable over ssh straight away.

Then add your ssh key, so provisioning can reconnect after it reboots the board:

ssh-copy-id radxa@192.168.1.42

What you need

  • The board's IP address. mDNS on this image is unreliable, so a .local name resolves when it feels like it — use the address from your router's DHCP lease.
  • ssh key access, from the step above. Provisioning reboots the board and reconnects by itself, and a password prompt cannot survive that.
  • A GitHub token. This repository is private, so its release assets are unreachable without one.
  • A clone of this repo. The dev key it needs is committed at deploy/dev-key/team.dev.pub, so there is nothing to ask anyone for.

Install

From a clone on your own machine, two commands:

export DUCK_TOKEN=github_pat_replace_with_your_token
./scripts/provision-board.sh --pause-btd-on-pair --name <MY_COOL_ROBOT_NAME> radxa@192.168.1.42

That sends your dev key, starts provisioning, waits out the reboot, streams the log, and ends on robotctl health.

Why --pause-btd-on-pair is in that command

On the aic8800 radio a pad cannot form a new bond while btd is advertising. That flag leaves a marker so robotctl pad pair stops btd and power-cycles the adapter for the pairing window, then starts it again. An existing bond is unaffected — a bonded pad connects and drives with the whole stack up — so the cost is one daemon being down for the length of a pairing.

It is the default here because a board that needed it and was provisioned without it presents as a gamepad that will not pair, and every plausible cause you chase first is somewhere else.

The three configurations, and how to tell which you have

There are two independent faults, so there are two flags. Pair a pad and read the failure, then pick:

what you see what the board wants
the pad bonds and drives nothing — provision with no flag
the pad will not bond; the last SMP step never completes --pause-btd-on-pair
the pad will not bond even with btd paused --weird-ble (implies the pause, and adds Privacy = device)
the pad bonds, then flapsPIN or Key Missing (0x06), no input device --weird-ble is wrong for this board: drop it, keep the pause

That last row is the one to watch for. Privacy = device on a board that only needed the pause produces a bond that immediately stops working, which is harder to diagnose than a pad that plainly will not pair — measured on 50:37:CD:16:1D:90, where off plus the pause bonds and holds while device flaps 46 times in 45 seconds. --weird-ble is therefore not the default any more.

To move a board from --weird-ble to the pause alone, keeping the marker:

sudo sed -i 's/^Privacy = device/Privacy = off/' /etc/bluetooth/main.conf && sudo reboot

To go the other way, with the copy of the script provisioning leaves on the board:

sudo DUCK_WEIRD_BLE=1 /usr/local/sbin/robot-setup-board && sudo reboot

And to check a board needs neither, drop both and pair a pad:

sudo rm /var/lib/robot/weird-ble
sudo sed -i '/^Privacy = /d' /etc/bluetooth/main.conf && sudo reboot

Re-pair after any change to Privacy: it changes the address the stored keys were derived against, so existing bonds stop matching and flap with PIN or Key Missing until they are re-made.

All of this is a workaround for the aic8800 radio, not a property of the design; it goes when the radio does. pair-a-gamepad.md has the detail.

It is a viewer, not the thing doing the work — provisioning installs a systemd unit that resumes at boot, so the board finishes whether or not you are still watching. Ctrl-C costs you nothing, and you can pick the log back up:

ssh -t radxa@192.168.1.42 'sudo tail -f /var/lib/robot/provision.log'

--ref BRANCH provisions from a branch: its scripts run the bring-up, and its build of the daemon is installed on top. golden stays the stable release — it is the boot recovery net's fallback, and a branch build as golden would give a broken branch a broken fallback — while current is the branch.

Provisioning fails if that build cannot be installed, or if it is installed and then rolled back by the health gate. A dev board quietly running the stable release when a branch was asked for is the worst failure to debug: everything looks installed and the code under test is not there. Give CI its minute or two before provisioning, and check with gh run list --branch BRANCH if it stops.

Other useful flags: --name Ducky names the robot instead of leaving it the duck-7f3a it derives from its own serial (robotctl system set-name changes it later, so this only saves a command), --local sends this clone's provision.sh instead of fetching it (which is how to test a change to the provisioning scripts without merging first), and --no-dev-key makes a board that only takes releases.

Check it worked

robotctl health

The board only counts as a dev board if the key really installed, and that is a thing you can check rather than a thing you have to remember:

grep -c 'DEV BOARD' /var/lib/robot/provision.log

1 means yes. 0 means the key did not land, and --ref will be refused later with an error that reads like a corrupt release. That is the failure this check exists to catch early.

Then the real test — put a branch on it:

sudo robotctl update apply --ref main daemon

When ssh refuses to connect after a reflash

Reflashing regenerates the board's host keys, so the address you used last time now presents a different one. StrictHostKeyChecking=accept-new does not cover it: the host is not new, its key is. The raw ssh error for this is a wall of text about a possible attack.

./scripts/provision-board.sh radxa@192.168.1.42 --forget-host-key

This matters more than it sounds, because DHCP leases get reused — the address that was one board last week is a different board today, with a different key.

When the board comes back at a different address

The wifi cutover in the middle of provisioning can leave the board on a different lease than the one you gave. provision-board.sh goes looking: while it waits for ssh it also asks the robot over Bluetooth what address it ended up with, and adopts the answer.

  bluetooth: the robot reports 192.168.1.57, and 192.168.1.42 was its old lease.

Nothing to do — the rest of the run is addressed there.

Three things stop it working, and it says which:

  • It needs cargo and this clone, because duck-btctl is an example rather than an installed binary.
  • It can only ask once btd is running, which on a board being provisioned for the first time is a few minutes into phase 2.
  • The robot reports its wifi address, so a board you reach over ethernet is not covered.

--no-ble turns it off. A robot that has been given its own pairing PIN needs it in DUCK_PIN.

Making an existing board take dev builds

For a board provisioned some other way, or one set up before you had the key. Both halves are needed: either alone leaves a board that still refuses branch builds.

The easy way is to re-run the installer with the key, which validates it and flips the flag in one step:

sudo DUCK_TOKEN="$DUCK_TOKEN" DUCK_DEV_KEY=/tmp/team.dev.pub sh /tmp/install.sh

It installs the key as team.dev.pub whatever the source file was called. That name matters: the .dev. infix is what classifies a key as a dev key, and a key landing under any other name is trusted as a release key.

By hand, if you would rather see each step:

sudo cp team.dev.pub /etc/robot/trusted_keys/team.dev.pub
sudo sed -i 's/^allow_dev_keys.*/allow_dev_keys        = true/' /etc/robot/updater.toml
sudo systemctl restart updaterd

The token, by hand

scripts/install.sh writes this for you when given DUCK_TOKEN. These steps are for a board provisioned some other way.

updaterd reads GITHUB_TOKEN from its own environment, so exporting it in your shell does not reach the daemon — it needs a systemd drop-in.

sudo mkdir -p /etc/systemd/system/updaterd.service.d

Substitute your own token in the next block — it is the only placeholder here:

sudo tee /etc/systemd/system/updaterd.service.d/token.conf > /dev/null <<'EOF'
[Service]
Environment=GITHUB_TOKEN=ghp_replace_with_your_token
EOF

A drop-in is world-readable by default, and this one holds a credential:

sudo chmod 600 /etc/systemd/system/updaterd.service.d/token.conf
sudo systemctl daemon-reload
sudo systemctl restart updaterd

A token on a developer's board is fine. A token on a customer robot is not, and is why artifact hosting is still an open question — see docs/design/updater-design.md §6.1.

Installing without a network

On a board that already has a release, install a local directory the ordinary way — through the daemon, with the health gate and auto-rollback:

sudo robotctl update apply daemon --from /media/usb/release

That is also what scripts/dev-push.sh ends with; dev-push.md is the laptop-to-board path.

The rest of this section is the bare-board case: a factory or offline install, before there is a daemon to ask. It is updaterd rather than robotctl for that reason, and updaterd is deliberately not on PATH:

sudo /opt/robot/daemon/current/bin/updaterd install --from /media/usb/release

The directory holds what a release is: <version>.manifest.json, its .minisig, the artifact and the artifact's .minisig. Signatures, hashes and compatibility are checked exactly as they are for a download — --from changes where the bytes come from, not what is trusted.

That command refuses to run once a release is live, because it forces on_apply and the health gate off, and doing that to a working robot would silently disable auto-rollback. One situation needs it anyway, and robotctl update apply cannot help with it — a board whose installed updaterd is too old to accept the release that fixes being too old. It rolls the new release back every time, and the binary running that gate is the one being replaced. Stop the robot and say so explicitly:

sudo systemctl stop robotd
sudo /opt/robot/daemon/current/bin/updaterd install --from /media/usb/release --force

--force is itself refused while robotd is still answering, since the objection is about a working robot losing its safety net. It gives up auto-rollback for that one install and nothing else — signatures, hashes and compatibility are still checked, and sudo robotctl update rollback daemon is the recovery path if the release misbehaves.

Going deeper

deploy/README.md is the reference for what all of this actually does: the trust chain, what ends up where, the other ways in (on the board without a clone, by hand step by step), where logs go and what survives a reboot.