Talk to a robot from a laptop, with no network and no ssh. It is the phone app's stand-in, and the way to reach a robot that has never seen a wifi network.
Bluetooth LE is how it gets there today, and the name deliberately does not say so: mediad gives
a robot a second transport that reaches a different set of methods, so the tool is named for what
it talks to rather than for the radio it currently uses. It was duck-btctl while BLE was the only
answer.
Never on a robot. Nothing in a release depends on it β robotctl is the tool that ships, and
cheatsheet.md has its commands, most of which have a duckctl equivalent
below.
Run it from a clone of this repo:
cargo run -q -p duckctl -- --name <robot-name> infoOr install it once, at the cost of a snapshot that no longer follows the branch:
cargo install --path duckctlduckctl --name <robot-name> infoEvery command below is written in the installed form. Prefix it with
cargo run -q -p duckctl -- to run it from the clone instead.
This tool used to install itself as btctl. If which btctl still finds one, it is a build from
whenever you installed it and it will never change again:
cargo uninstall btd --bin btctlduckctl scan1 robot(s) advertising the duck service:
aa:bb:cc:dd:ee:ff duck-c51b β 192.168.1.42, 1 service(s) β DUCK_ROBOT
7 other device(s) in range, not listed. β¦
Robots only, with everything else in radio range counted rather than listed. --verbose expands
that list, and it is worth reading when the robot you want is not in the first one.
Each robot broadcasts its IPv4 address, so this is also where the address to ssh to comes from. No
connection is made and no PIN is needed. no address on the line means the robot is not on a
network; a line with no address at all means a release from before robots broadcast one, and
duckctl wifi status still reports it.
The SSID is not in the listing β it does not fit in an advertisement. duckctl wifi status has
it, along with the signal and both addresses.
For the address on its own:
ssh radxa@$(duckctl ip)ip prints the address and nothing else, so it substitutes. It reads the advertisement, so it
connects to nothing, needs no PIN, and takes about a second β and the answer is not stale: btd
re-reads the address every five seconds and re-advertises when it moves.
Or skip the substitution:
duckctl sshduckctl ssh -- sudo robotctl pad pairssh finds the address the way ip does and then becomes ssh, so the prompts, the terminal and
the exit status are ssh's own. The account is --user, else DUCK_BOARD_USER from the environment
β the variable dev-push.sh reads, so a laptop set up for pushing is set up for this
β else radxa. Words after -- run on the robot instead of opening a shell.
A robot bonded to this machine often stops advertising the service to it, and then ip connects and
asks net.status instead. That is slower and needs the PIN, and it always answers. --verbose says
which of the two happened.
A robot with no network address is told what to do about it rather than reported as empty, because
the fix is over the radio and has to be β net.connect is refused over WebRTC by design.
A robot that has never been renamed calls itself duck- plus four characters derived from its
serial, so duck-c51b. Either half of a robot reported under two names at once β macOS shows
radxa-zero3 [duck-c51b] β works as --name.
A robot that scanned as duck-c51b and then, after one connection, only as radxa-zero3 is on a
release that gave its name to the advertisement but not to the adapter, and the client cached the
adapter's. Update the robot. That does not clear what the client already cached, so clear it too:
bluetoothctl remove <mac> on Linux, or forget the robot in macOS Bluetooth settings.
With no name at all β no --name, no DUCK_ROBOT β the first robot found wins. With a name, two
robots answering to it is an error rather than a choice:
2 robots answer to "radxa-zero3": radxa-zero3, radxa-zero3
That happens on a board whose bootloader leaves its serial blank, because it is then named after its hostname and every board flashed from one image has the same one. Rename one from the robot itself and use the new name:
robotctl system set-name duckyThe robot serves a page that streams its camera and drives it:
duckctl openFinds the robot, then opens http://<address>:8080/ in a browser. --print gives the URL instead,
for a machine with no browser or for a script; --port for a robot started with a non-default
mediad --web-port.
Nothing to install and nothing to serve β mediad embeds the page, so a robot running that daemon
is a robot with a console.
What is on it: the camera with the link's bitrate, frame rate, loss and round trip beside it; two
pads and the keys W/A/S/D and Q/E to drive, at a gamepad's 0.3 m/s and 1.5 rad/s; a drag
on the picture to look at a point; enable, init, relax, stop and shutdown; the voice bank as a
menu and the skills as a menu the robot fills β which skills a robot has is config, so the
page asks robot.policies rather than offering a list it guessed; and the state stream at 2 Hz beside robot.health, which is where a hot servo, a flat
pack or a loop running slow gets named.
stop zeroes the intents the page is sending. It is not an emergency stop β nothing in this
system cuts servo power from a browser β and the button is a plain one for that reason.
The raw JSON box, the log, and the two calls a WebRTC peer is refused are in the drawer at the bottom. They prove the route table is being consulted rather than drive the robot.
Two ports are involved and only this one is typed: the page reaches the signalling server on 8443 itself, using the host it was served from. If the page loads and then says its signalling port did not answer, the robot is up and something between you and 8443 is not β a firewall, most often.
The camera and the drive controls are on WebRTC and nothing else, so a robot with no network address
has no console. Join it to one over the radio first β duckctl wifi connect below needs no network
of its own.
Put the name in the environment instead of on every command line:
export DUCK_ROBOT=duck-c51bPut that line in ~/.zshrc to keep it. Every command below then works without --name:
duckctl infoDUCK_PIN does the same for --pin, which a robot with a PIN of its own needs on every command:
export DUCK_PIN=418299For one command against a different robot, --name still wins:
duckctl --name duck-ffff infoTo ignore the default for one command β a bench with somebody else's robot on it β set it to nothing:
DUCK_ROBOT= duckctl scanscan marks the robot DUCK_ROBOT names and lists it first, and every command that goes looking
for it says so before it starts scanning.
duckctl --name <robot-name> infoName, serial and uptime.
duckctl --name <robot-name> name <new-name>Up to 24 characters. It takes effect within a few seconds and needs no restart, but the Mac keeps
serving the name it learned earlier, so scan and macOS Bluetooth settings both lag behind. Every
later command uses the new name.
A rename does not follow DUCK_ROBOT. The tool says so afterwards; the variable has to be changed
by hand, or every later command looks for a name that no longer answers.
duckctl --name <robot-name> rebootduckctl --name <robot-name> wifi statusSSID, signal and addresses.
duckctl --name <robot-name> wifi scanTakes a few seconds β the robot sweeps the radio rather than returning the previous scan.
duckctl --name <robot-name> wifi connect <ssid> --psk <passphrase>Omit --psk for an open network. Joining disconnects the robot from the network it is on, so an ssh
session over wifi drops; that is the command working. It can take up to 45 seconds to answer.
duckctl --name <robot-name> wifi forget <ssid>duckctl --name <robot-name> healthWhether the control loop is healthy.
duckctl --name <robot-name> statusThe version handshake and the update status.
duckctl --name <robot-name> versionThe API version, the release, and the git revision it was built from. A revision of null means
the release was built on somebody's laptop rather than by CI.
duckctl --name <robot-name> logs robotdThe last 40 lines of that daemon's journal, this boot. For more, and for the boot before this one:
duckctl --name <robot-name> logs robotd -n 200duckctl --name <robot-name> logs btd --boot -1Readable units: updaterd, robotd, configd, btd, padd, mediad, tofd, plus
bluetooth and NetworkManager. The .service suffix is optional, and anything else comes back
refused with that list.
Lines go to stdout and everything else to stderr, so logs robotd -n 200 | grep -i panic works.
A long tail is trimmed to what the radio can carry, oldest lines first, with a note saying so.
A tail that spans a restart says where:
2026-09-09T12:27:20+00:00 systemd[1]: Starting robotd.service - Robot control daemon...
-- new robotd process, pid 3227 --
2026-09-09T12:27:21+00:00 robotd[3227]: control loop running joints=15 hz=50.0 driving=true
Which matters after an update, when forty lines carry two different builds' output. Anything in
-- β¦ -- comes from the robot rather than the journal.
There is no -f, no --since and no search. For those, ssh in:
ssh radxa@$(duckctl --name <robot-name> ip)journalctl -u robotd -fSame words as robotctl update, so a command learned on the robot works here. Every one of them
takes --component <name> and defaults to daemon, which is the only component a robot has today.
duckctl --name <robot-name> update checkduckctl --name <robot-name> update statusduckctl --name <robot-name> update versionsduckctl --name <robot-name> update log --limit 20Installing takes minutes and prints progress lines as it goes:
duckctl --name <robot-name> update applyΒ· daemon: preflight
Β· daemon: downloading 12%
Β· daemon: downloading 47%
Β· daemon: verifying
Β· daemon: swapping
Β· daemon: health_gate
{
"outcome": "applied",
"from": "0.5.1",
"to": "0.6.0"
}
note: the robot restarts its daemons now, and `btd` about five seconds after this reply β so this
connection drops. That is the update working. Reconnect and run `duckctl update status`:
`last_attempt` carries the outcome of what just ran.
The connection dropping after an apply is the update working, not a failure. Reconnect and read
update status.
A branch build, an exact version, or the staging candidate:
duckctl --name <robot-name> update apply --ref my-branchduckctl --name <robot-name> update apply --version 0.5.1duckctl --name <robot-name> update apply --staging--dry-run verifies everything and stops before the swap. --ref and --version are alternatives;
asking for both is refused.
Going back β the previous release, or one named from update versions:
duckctl --name <robot-name> update rollbackduckctl --name <robot-name> update select 0.5.1Both are gated like an apply, so one that does not come up is reverted. Neither discards anything.
Progress for an update somebody else started, or one triggered by the robot itself:
duckctl --name <robot-name> update watchIt prints where the update in flight has got to and then everything that follows. It never receives a reply, so it ends with Ctrl-C.
duckctl account login
Prints a code and opens https://hf.co/oauth/device, where you type it β Hugging Face's device page does not accept a code in the URL, so opening saves the navigation and not the typing. The robot does the waiting, so this tool disconnects as soon as it has printed the code β approve it in the browser it opened, or from any other device, then:
duckctl account status
duckctl account logout
This is the one thing that works on a robot that has never seen a network: no wifi means no console and no LAN, and Bluetooth is what is left. Signing in over BLE is the same flow the setup wizard runs.
duckctl account login --no-open
for the code and the URL without a browser β which is also what you get automatically when the output is not a terminal, so a script launches nothing.
A robot already signed in refuses and names the account, and so does one still waiting for a code
to be approved. --force replaces either β the abandoned code stops working.
What each slot runs, and which skills this robot has:
duckctl policy listThe skills array is the answer to "what can this robot be asked for". They are config, so it
differs between robots and there is no list to assume β read it before offering a button.
Run one:
duckctl do rouladeThe robot has to be driving. Press Start on the pad first, or it answers saying so. It needs no pad input though: the deadman zeroes the twist by itself, so a robot nobody is steering stands still and does the thing.
Change what it walks with, live:
duckctl policy load walk /opt/robot/policies/current/alpha_walking.onnxPut that slot back:
duckctl policy reset walkOne slot at a time, because the wire call takes one β resetting all seven is
robotctl policy reset on the robot. The path is on the robot, and must be absolute.
A load from here survives a reboot, exactly as robotctl policy load on the robot does: the
daemon writes the slot into robotd.toml before it swaps. duckctl policy reset <slot> is the
undo.
Re-read every slot from the config file, for when something else changed it:
duckctl policy reloadThe robot goes to its home pose with torque on, loads, and drives again β a few seconds, and the
timeout allows for it. A file that is not obs[1,61] -> actions[1,14] is refused before anything
changes, and a load that fails anyway keeps the policy that was running.
What else is published for this robot, and whether the official set has moved:
duckctl policy search microduckduckctl policy checkInstall the newest official set β or --version v1 to go back:
duckctl policy updateDownload somebody else's, without running it:
duckctl policy fetch RemiFabre/microduck-flamingo-cycleThe reply names the path it landed at. load takes a path and never org/repo: fetching and
loading are two calls here where robotctl spells both with one string.
fetch downloads a file; this makes it something robot do answers to:
duckctl policy skill polite-bow --path /var/lib/robot/policies/fffiloni/microduck-polite-bow-b1d864/main/policy.onnx --duration 4duckctl do polite-bowOne call β the robot writes its config and reloads, so nothing restarts. --duration is required
the first time and kept afterwards, so changing one field means sending one field:
duckctl policy skill polite-bow --command 1,0,0--command is the twist fed to the network while it runs, zeros unless the policy reads its twist
as something else β flamingo's is [flag, side, 0]. --unwind and --unwind-s are how a policy
that holds until told otherwise is brought back.
What this robot can be asked to do, and the timings behind each:
duckctl policy skillsbuilt_in in that answer names ground_pick and sit_toggle, which robot do also accepts but
which are driven by the robot itself and are not table entries.
duckctl policy unskill polite-bowA skill this robot's release ships comes back when you do that, because removing the entry only removes the override.
Nothing a stranger publishes is verified by anybody. What makes it safe to try is the manifest gate before the download, the shape gate at load, the joint clamps and the fall reflex β not the description. Have the robot on its stand the first time.
duckctl pad bindingsduckctl pad bind x polite-bowduckctl pad reset xNothing restarts and nothing else is needed β padd re-reads the file within a second. The
name is checked against this robot's skills first, so a typo comes back as a refusal naming the
real ones rather than becoming a button that does nothing when pressed.
The listing marks two things a client cannot work out for itself: overridden, so somebody's
changes are visible without knowing the defaults, and error, for a button bound to a skill this
robot no longer has β the realistic way to get one of those is removing a skill, not mistyping.
"" switches a button off, which is a different wish from pad reset putting it back to what the
robot ships with. Five buttons are bindable: a, x, lb, rb, dpad_down β and lb/rb are
the bumpers, since the analog triggers are the mouth and the quack.
Pairing a pad is a different namespace and a different daemon β pad.pair and pad.forget are
configd's, reached with call. These two are robotd's, because checking a skill name needs
the list of skills.
duckctl --name <robot-name> call <method> '<json-params>'Params default to {}. These are reachable over Bluetooth but have no wrapper of their own, and are
written without the duckctl --name <robot-name> in front of them:
call system.services |
Which daemons are up, and the release each runs. |
call pad.status |
Is a gamepad bonded, and is it connected? |
call pad.pair '{"timeout_seconds":30}' |
Bond a pad held in pairing mode. |
call pad.forget '{"mac":"<address>"}' |
Drop a bond. |
call waits 60 seconds for an answer. The update commands above wait on silence instead β three
minutes with nothing arriving at all β which is why they are the way to run an update rather than
call update.apply.
--name <robot-name>β which robot. Without it,DUCK_ROBOT; without that, the first one found wins. Worth giving always: it skips a slow fallback tier that tries every already-connected peripheral on the Mac, earbuds included.--pin <six-digits>β defaults toDUCK_PIN, then to000000.robotctl system pinon the robot shows the real one.--verboseβ print every line sent and received, and havescanlist every device rather than only the robots. The first thing to add when something hangs.
Replies go to stdout as pretty JSON, and everything else β progress, diagnosis, what the radio
saw β to stderr. logs is the exception and prints its lines as lines, since a journal tail in
escaped JSON is unreadable; a refusal from it still prints as JSON. So duckctl ... info > reply.json keeps the two apart, and a JSON-RPC error
from the robot still exits non-zero. Progress lines start with Β· and are one line each, so
update apply > outcome.json leaves them on screen and keeps the outcome in the file.
One command is one connection: it finds the robot, pairs if it has to, proves the PIN, asks, and disconnects.
Every command gives up after a period of silence rather than after a fixed total, so a slow update
is never cut off and a robot that stops answering is reported in seconds. A link that drops is
reported at once rather than waited out, and after an apply it says so: the restart is what took the
connection down. An update in flight survives either: the robot pulls, so it carries on with nobody
watching, and update status afterwards says how it went.
Teleop (robot.move, robot.head, robot.enable, robot.stop, robot.init, robot.relax),
high-rate telemetry (robot.subscribe), the two update commands a person has to mean
(update.pin, update.resetToGolden) and the pairing PIN (system.pairingPin,
system.setPairingPin) are refused by btd itself and never reach a daemon.
robot.do is not in that list, though it moves the robot: teleop is a stream of fifty small
updates a second, which is what a 20-byte notification budget cannot carry, and a skill is one
request. They come back as
error code 14, "not available over Bluetooth".
That is a security boundary rather than a missing feature, and each refusal has its reason next
to it in btd/src/route.rs β app-path-design.md Β§3.1 is the
design.
Those commands are robotctl on the robot.
duckctl --verbose scanAn empty list β not one pair of earbuds β points at the Mac rather than the robot: Bluetooth off, or the terminal never granted the Bluetooth permission.
A list the robot is missing from points at the robot. It advertises its name in a scan response that
can be missed on its own, so a device reported with no name and no services is a plausible robot;
--name connects to one anyway. If macOS shows the robot as paired but a connection or the first
read hangs, the bond is half-finished:
sudo pkill bluetoothdForgetting the robot in macOS Bluetooth settings does the same thing. On the robot itself,
journalctl -u btd -b says whether the GATT application registered at all.