Skip to content

Latest commit

Β 

History

History
793 lines (599 loc) Β· 32.3 KB

cheatsheet.md

File metadata and controls

793 lines (599 loc) Β· 32.3 KB

Cheat sheet

robotctl, which runs on the robot. Every command here was taken from --help on the branch that ships it, not from memory.

Read-only commands need no privilege. Anything that changes the robot needs sudo (or a user in --allow-user/--allow-group for configd, allow_uids/allow_gids in updater.toml for updaterd).

Branch builds, release candidates and the restart traps after an update are in cheatsheet-dev.md β€” they need a dev board. The same robot over Bluetooth from a laptop, with no network and no ssh, is duckctl.md.

On the robot β€” robotctl

The first thing to run

robotctl version

What every daemon is running against what is installed, plus warnings when they disagree. Run this before believing any other diagnosis β€” a daemon serving old code after an update looks exactly like a bug in the fix you just shipped. See "After an update" below.

robotctl health

Hardware and software in one report. Exits non-zero when the robot is unhealthy or unreachable, so it can gate a script β€” a hot motor or a pinned component is reported, not judged, and does not affect the exit code. --json for a support bundle.

Watching the loop

robotctl monitor

What a client asked for beside what was actually applied, with the reason named 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. Projected gravity is the only IMU quantity on this stream β€” upright is about [0, 0, -1], and it is what fallen is decided from. The stale-read counters and the ratios they mean anything against live in robotctl health.

The last row of the header is the robot's condition rather than its behaviour: the pack's charge in volts and as a fraction, the hottest servo and the board's own temperature. It comes from robot.health, polled every two seconds, because none of it is on the state stream β€” and it is where anything wrong gets named, whether that is unhealthy: control loop at 43.9 Hz, degraded: no robot on the motor bus after 3 attempts or orientation frozen β€” 25 stale reads. That last one is on this row and nowhere else on the frame: a board that has stopped fusing keeps answering the bus, so nothing errors and the gravity vector above holds a plausible attitude indefinitely.

0% is BATTERY_EMPTY_V, which is where robotd sits the robot down and cuts power, so the figure is a countdown rather than a gauge β€” yellow at 30%, red at 15%. A reading that has not been taken says batt not read yet instead of 0.00 V, which is what the first second of an uptime and a bus that cannot answer both look like. The row is drawn even when there is no state at all, and that is the case it matters most in: a board whose servo power is off never completes a control tick, so nothing arrives on the stream and the reason is only on the health answer.

The bottom border names the policy that is loaded β€” the .onnx files, and whether a standing network is configured at all β€” because walk is a mode two releases with different gaits both report. A robot with no policy says so, and one whose policy would not load says that instead, which the stream's held cannot distinguish.

Down the right-hand side, the robot drawn as it is standing β€” the same visual model the policies were trained against, posed by the measured joint angles and tilted by the IMU's gravity vector. A leg folded the wrong way, a head pitched into the floor and a duck lying on its side are all just numbers in the joints table; every one of them is obvious here. It is on by default and appears whenever the terminal is wide enough (about 110 columns β€” the tables come first, and the robot takes what is spare). d turns it off; [ and ], or ←/β†’, orbit it.

Whenever the ToF is delivering frames, what it sees is drawn into the same scene β€” yellow for a hit, green for floor β€” depth-tested against the robot's own body, so a point behind the beak is hidden by it. That is what makes "is it seeing my hand, or is it seeing itself" answerable. It needs no key: the points appear when frames arrive and go when they stop.

Under it, when the column is tall enough, a map of where the robot has been: odometry's track from the foot contacts and the IMU, in braille. The panel never grows β€” the world zooms out as the track does, so the whole path stays in frame. + is where it started, ● is the robot with a short ray for its heading, screen-up is the heading it booted with. There is no magnetometer, so this is relative motion and it drifts; it answers "did it walk in a circle" and not "where is it".

q quits; ↑/↓ scroll the joint list on a window too short for all of it; u switches the angles between degrees and radians; t opens the ToF matrix; d toggles the robot view and [ / ] orbit it; p opens the pad's raw input stream β€” every evdev report from the gamepad, with the gaps between them, which is the only place a stalled radio is visible (pair a gamepad). 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, which carries the whole state, one object per line:

robotctl monitor --json --hz 50 > run.jsonl

Configuring the robot

sudo robotctl configure

An interactive editor over /etc/robot/robotd.toml: every key the daemons know, the feature switches first (policy on/off, walk/roller, limp-fall, audio, pet detection, battery shutdown, camera and video quality…), current value against default, one line of doc. SPACE toggles, ENTER types a value, u reverts a key to its default. Values in yellow (marked β€’) are the keys where this robot diverges from the defaults; everything else is the built-in default, and unset optionals show what they resolve to (auto).

Three properties worth trusting:

  • It cannot disagree with the daemon. The schema, the defaults and the validation come from the same crate robotd parses the file with, and the key list is pinned complete by a test β€” a new [section] in the daemon shows up here or the build fails.
  • It cannot eat your file. Comments, ordering and keys from other releases survive untouched; only the keys you change are written. Reverting a key removes it (and the comment attached to it) rather than pinning the default, so the file stays a list of decisions, not a copy of the defaults.
  • It cannot write a file robotd refuses to start on. Every save is validated through the daemon's own loader first, atomically (temp file + rename), and rejected with the reason.

The daemons read the file once at startup, so saving offers a restart β€” of the ones that read what you changed: [media] is mediad, everything else is robotd. sudo, because the file is root-owned β€” without it the editor opens read-only and says so on the first write. --file points it elsewhere for a bench copy. The shipped deploy/robotd.toml stays the reference for why each knob exists; this is for flipping them.

Video quality

sudo robotctl configure

Set media.quality β€” 1080p30, 720p30, 720p15 or 360p30 β€” and take the restart it offers. media.camera off streams a test pattern instead, which is what a board with no camera wants: the WebRTC control channel rides on the video track, so a pipeline that cannot start costs both. media.bitrate follows the quality unless you set it; the unit is bits per second.

media.congestion_control is the other knob in that section, and it is the one that moves CPU: disabled drops the bandwidth estimator, which is the largest single consumer in mediad (7.6% of a core against capture's 0.3%), and makes media.bitrate the rate rather than a starting point. It costs adaptivity β€” on a link that degrades, the picture stalls instead of the rate falling.

720p30 is the rung the pipeline was measured at; a rung that does not hold runs slower rather than failing. robotctl monitor reports the achieved rate on the bottom border, in yellow with of <target> beside it when it is under 90% of what was asked for. What was applied:

journalctl -u mediad -b | grep streaming

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 β€” a release replaces the binaries and the policies it ships, not the file that points elsewhere. Delete the lines to go back to the ones the release carries.

A policy that could not be loaded reports unhealthy, and robotctl health and the bottom border of monitor both name the reason. The shape a policy has to have, and what else is checked at load, are in ../design/robotd-design.md Β§2.3.

Power to the joints (robotd)

sudo robotctl robot init
sudo robotctl robot relax --yes

init powers the joints and ramps to the home pose over about two seconds β€” it moves every joint, so have the robot on its stand. It needs no policy, and it is what the gamepad's Start does on its way to driving, so by hand it is a bench thing.

relax cuts power and the robot collapses if nothing holds it, which is why it wants --yes. It is the only way back to limp short of pulling the plug: pressing Start again stops the policy and keeps the robot standing, and robot.stop zeroes the velocity while still standing.

Both go through robotd, which owns the motor bus. robotd init β€” the subcommand β€” still exists for a robot whose daemon is not running, and it needs the daemon stopped, because two writers on one UART corrupt each other's replies:

sudo systemctl stop robotd && sudo /opt/robot/daemon/current/bin/robotd init && sudo systemctl start robotd

init works whether or not the robot has fallen β€” by default a fall is a report (visible in robotctl monitor), not a gate, matching the prototype. A board that sets [safety] fall_limp or fall_recover in robotd.toml arms the gate: there a fallen robot goes limp and refuses init/enable/skills until it is stood up.

Gamepad (configd)

robotctl pad status
sudo robotctl pad pair
sudo robotctl pad pair 78:86:2E:BB:13:28
sudo robotctl pad forget 78:86:2E:BB:13:28

Pairing is once per pad and has a page of its own β€” pair-a-gamepad.md: which button puts a pad in pairing mode, adding a second pad without forgetting the first, and what to do when it will not bond (the Privacy setting in /etc/bluetooth/main.conf is the answer more often than anything else).

padd.service runs from boot and drives whatever pad connects, so pairing is the only step. The mapping is the prototype's, so muscle memory carries over:

control does
left stick drive: forward/back and strafe Β· head: head yaw and pitch Β· body pose: up and crouch
right stick drive: turn Β· head: neck pitch and head roll Β· body pose: pitch and roll
Start toggle the policy β€” nothing moves until it is on
Y / triangle head mode: sticks pose the head (body holds still)
B / circle body-pose mode: sticks lean and crouch the standing robot
A / cross ground pick
X / square roulade β€” one forward roll; hold to chain rolls
LB / RB left / right kick
DPad-Down sit ↔ stand
RT / LT mouth (either trigger) β€” RT also quacks; LT rides the "wheee" while held
DPad-Up, held 3 s switch drive mode, walk ⇄ roller
Select, held 2 s sit down, then power off

There is no stop button: release the sticks and the robot stands, and robotd's deadman stops it if padd dies. On a roller robot (mode = "roller" in robotd.toml) the sticks take the roller shaping automatically β€” asymmetric push/brake, no strafe β€” and A triggers the crouch. The other skills ride along: sit, kicks and the roulade work on wheels too, as the prototype has it.

Holding DPad-Up switches between the two, for when you have just put wheels on the duck or taken them off: the robot quacks once for walking or twice for roller, returns to its home pose, loads that mode's policies there and drives again β€” a few seconds, torque on throughout, no restart. robotd.toml is not touched, so a reboot comes back in the configured mode; make it stick with robotctl configure (or [policy] mode). It is a hold rather than a press because D-pad up is easy to lean on while driving.

pad status answers two questions separately, because a connected pad and a dead driver look identical from the outside:

pad     Xbox Wireless Controller 78:86:2E:BB:13:28  connected
padd    active β€” driving whatever pad connects

To drive with non-default limits, stop the service first or two processes fight over the sticks:

sudo systemctl stop padd
sudo -u padd /opt/robot/daemon/current/bin/padd --max-linear 0.25

When the link itself is the suspect, watch it live β€” robotctl monitor, then p. That works with no robot too: on a board whose servos are unpowered or whose robotd is stopped, the monitor opens on the pad block instead of refusing. For a verdict over a window instead, copy the measurement over from a clone of this repo:

scp scripts/pad-link-test.sh radxa@<board>:/tmp/

Drops already in padd's journal β€” no pad needed, and it answers immediately:

sudo sh /tmp/pad-link-test.sh --history

Or measure it now, keeping the sticks moving for the whole two minutes:

sudo sh /tmp/pad-link-test.sh

It counts drops against the kernel's own reason for each, and times the gaps between the pad's input reports β€” the failure padd cannot see, where the link stays up and the robot walks on a stale command. pair-a-gamepad.md reads the numbers.

When two boards behave differently with the same pad, the difference is in the stack under it:

scp scripts/pad-stack-report.sh radxa@<board>:/tmp/
sudo sh /tmp/pad-stack-report.sh

Kernel, BlueZ, controller firmware, LE or BR/EDR, and the pad's own firmware revision β€” printed and saved to /tmp/pad-stack-<host>-<when>.log. --fingerprint prints only the values that must match between two boards, for diff. pair-a-gamepad.md has the comparison.

The voice

robotctl quack

The loudest way to tell ducks apart: every robot's voice bank is generated from its SoC serial (sounds ensure-bank, run by every release install), so the robot that answers β€” in a voice that is only its own β€” is the one you're SSH'd into. A robot with no voice β€” audio off, or no bank β€” says so instead of printing πŸ¦†, so silence always means the wrong duck. The robot also greets when robotd comes up, pecks goodbye before powering off, and β€” if you ask it to β€” coos when the mic hears its head being scratched. That one is off by default in both modes (audio.pet_detect = true turns it on; the classifier ships in the release): the always-on version cooed at every incidental brush and wore thin. The startup greet has its own switch, for anyone restarting the daemon all day:

sudo robotctl configure

Set audio.greet = false and take the restart it offers on save. That silences the one quack and leaves the triggers and the mic alone, which audio.enabled = false does not. Audio hardware bring-up β€” codec driver, overlays, mixer β€” is setup-board.sh's audio section, once per board.

To audition a voice or regenerate the bank by hand, the release carries the generator:

/opt/robot/daemon/current/bin/sounds show
sudo /opt/robot/daemon/current/bin/sounds ensure-bank --force

sounds theremin auditions the live synth β€” the voice the theremin plays in, driven by a scripted hand sweep at the ToF's own frame rate. --out sweep.wav writes it instead of playing it, which is how you hear a voice change without a robot in front of you.

The duck chorale

robotctl chorale

Two ducks in a room sing a four-part piece together; more join what they find already going. Runs until Ctrl-C. --off stops one.

Off by default β€” [chorale] accept in robotd.toml, and it has to be set on every duck that should take part. A chorale moves the mouth and the head, so a duck that started animating because another duck walked in would be doing motion nobody asked for. Off also means invisible: a duck that has not opted in puts nothing on the air, rather than politely declining.

How it works, in the order the questions come up:

  • Nobody is in charge. Both ducks see the same beacons and the lower id conducts, so there is no election to lose and no message that has to arrive.
  • There is no shared clock. The boards have no NTP and no RTC agreement, so the conductor's beat counter is the timebase: it bumps a byte in a BLE advertisement once per beat, and the arrival of a new value is the downbeat. Followers average the phase over about 25 beats, which brings the radio's jitter inside the Β±20 ms an ensemble needs.
  • Parts are worked out, not assigned. The lowest duck sings bass. The conductor broadcasts the roster and everyone replays the same seating over it β€” which is what stops two ducks singing the same line when they can each see a different subset of the room.
  • Joining changes nobody's part. A duck arriving takes the part that is free. A duck that leaves keeps its seat β€” its line simply goes unsung, exactly as in a choir somebody walked out of β€” because reseating the survivors is the one thing worth avoiding mid-piece.

The readout names the part as soon as it is settled, so what a duck ended up singing survives in the scrollback:

listening for other ducks β€” Ctrl-C to stop
  singing tenor    with 3 voices
  tenor    bar   12  beat  45.2  3 voices

The conductor picks the piece per performance, and a performance ends β€” after the last note everyone goes back to listening, re-settles, and sings something else after a breath. robotctl chorale --piece 2 pins what this robot picks if it conducts (a follower sings what the beacon names, so set it on every duck to guarantee the song); unknown ids are refused with the robot's catalogue. Ids: 1 wistful, 2 duck-strut, 3 outer-wilds (test asset, not for release). DUCK_CHORALE_PIECE=<id> in robotd's environment is the standing fallback β€” note it must be on robotd, not on the robotctl command line.

To hear the arrangement without any ducks, one machine can render the whole ensemble:

sounds chorale --voices 4                 # or --seeds 100,7,42 for particular ducks
sounds chorale --score my-piece.mid       # anything a notation editor exported
sounds chorale --rolloff 0                # for a full-range speaker, not a duck's

Scores come from either sounds/scores/*.duckscore β€” a line-oriented text format, documented in wistful.duckscore, which is also the shipped piece β€” or a MIDI file, which is the path worth using: MuseScore is the score editor. One instrument per voice rather than one piano staff, name the parts, and export MIDI. Parts are matched by mean pitch, so a score written top-staff-first still puts the bass on the bass; a track named "Soprano" is believed over its pitches.

Play the duck (the ToF theremin)

robotctl theremin

The head's depth sensor becomes an instrument: a hand in front of the beak is the pitch β€” closer is higher β€” and the mouth opens with the note, wide at the top of the range. Runs until Ctrl-C and puts the instrument down on the way out. --off puts down one a client left up.

An explicit mode with nothing clever inside it: while it is up, the nearest return inside the playable band is the hand. Point the duck at open space and it is silent; point it at a wall 40 cm away and it plays a steady note. It plays sitting, standing or walking β€” the mouth is not part of any policy.

The readout's last column is what the sensor said about that frame, and it is the answer to every "why did it stop playing":

  0.34 m    438.1 Hz   60% β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ    14 usable Β· 255:38 4*:9 5*:5 1:12

How many zones carry a status the robot believes, then the count per ST status code with a * on the believed ones. A ~ before the note means it is a held note bridging a sensor dropout rather than something measured right now.

That column exists because of the bug it would have found in a minute: ST documents 5 and 9 as "range valid", and a build believing only those stops seeing a hand at about 30 cm β€” past that a moving hand comes back as 4 or 13 (consistency failed, sigma too high) carrying a distance that is perfectly good for a pitch. If the reach is short, add codes to [theremin] statuses in robotd.toml; if it plays phantom notes at empty air, remove some. hold_ms is the anti-chop: it rides over a flickering zone.

Note that robotctl monitor's ToF grid is stricter than the theremin β€” it marks anything outside 5/9 as x, could not measure. A grid full of x does not mean the sensor is broken; it means it is being pessimistic about numbers it does have.

The ToF sensor (tofd)

An 8Γ—8 depth matrix from the head sensor. robotctl monitor, then t:

β”Œ tof VL53L8CX Β· 15 Hz Β· 8Γ—8 Β· 48/64 ranged Β· 0.12–3.54 m ─────────────┐
β”‚ 0.12 0.15    x 1.44 1.86    Β· 2.70 3.12                              β”‚
β”” Β· nothing in range Β· x could not measure Β· nearβ†’far ── seq 412 Β· 6 ms β”˜

Distances in metres, coloured near-warm to far-cool. The two marks matter: Β· is measured, nothing in range β€” free space, which is information β€” and x is could not measure, which says nothing at all about what is out there. A grid that showed both as blank would hide the difference.

This is the sensor's own frame, not the robot's: there is no reprojection until the kinematics exist, which is also what makes the block the right place to check a mounting angle.

tofd owns the sensor and nothing else reads the bus. It is an ordinary service β€” sudo systemctl stop tofd is safe, nothing depends on it, and monitor says "no depth stream" and carries on. Three things it distinguishes, because they need different fixes:

the block says what it means
connecting to tofd… / no depth stream the daemon is not running
no sensor: … tofd is up; nothing answered on the bus (most ducks)
waiting for the first frame… a sensor is ranging; its first scan is ~66 ms away

To see what is on the bus by hand, or to watch frames without a terminal UI:

sudo i2cdetect -y -r 3
journalctl -u tofd -b

The sensor shares the codec's IΒ²C bus, so setup-board.sh's audio section already provisions the bus itself; the ToF step only adds the stable /dev/i2c-pihat name. Both sensor generations are supported β€” a VL53L5CX and a VL53L8CX are interchangeable on the board, and the daemon picks the driver from an ID read.

Wifi (configd)

robotctl net status
robotctl net scan
sudo robotctl net connect <ssid> --psk <passphrase>
sudo robotctl net connect <ssid> --psk-stdin
sudo robotctl net forget <ssid>

--psk-stdin keeps the passphrase out of ps, which shows a --psk argument to every user on the box for the lifetime of the command. Prefer it on anything shared.

Joining a network disconnects the robot from the one it is on, so an ssh session over wifi will drop. That is the operation working. A scan takes a few seconds β€” it waits for the radio to sweep rather than returning the previous scan's results.

Identity and power (configd)

robotctl system info
robotctl system pin
sudo robotctl system set-name <name>
sudo robotctl system set-pin <six-digits>
sudo robotctl system reboot

Out of the box a robot calls itself duck- plus four characters derived from its own serial, so two boards flashed from the same image still look different in a phone's Bluetooth list. Renaming takes effect over Bluetooth within a few seconds β€” no restart β€” but a phone has to scan again to see it.

The PIN is what a phone authenticates with over Bluetooth. The factory default is 000000, which authenticates anyone who has read this repository.

Updates (updaterd)

robotctl update status
robotctl update check daemon
sudo robotctl update apply daemon
sudo robotctl update rollback daemon
robotctl update log
robotctl update show
robotctl update watch

log lists attempts, one line each, newest first; the first column is the run number. show takes one of those numbers β€” or nothing, for the most recent β€” and prints everything that run did, then the journal for the same window:

run 42 Β· daemon Β· 2025-08-27 13:06:40 UTC
  applied 0.1.3 β†’ 0.1.4
  asked for latest, from github.com/pollen-robotics/microduck, onto 0.1.3
  requested by uid=1000 gid=1000 pid=2317

  13:06:41      +1s  manifest     0.1.4 Β· 184.2 MB Β· sha256 3f9a1c2b… Β· signed by release.pub Β· rev 88efc03
  13:06:41           downloading
  13:07:58   +1m17s  note         downloaded 184.2 MB to /opt/robot/daemon/staging/0.1.4/dl/…
  13:08:02      +4s  note         hash matches; signature verifies against release.pub
  13:08:20     +18s  pre-hook
  13:10:12   +1m52s  hook         hooks/preinstall
                                 β”‚ onnxruntime 1.20.1 already present
                                 β”‚ gstreamer: h264 encode ok
  13:10:12           swapping     0.1.3 β†’ 0.1.4
  13:10:14      +1s  unit         robotd: restart
  13:10:23      +8s  health       the robot reported healthy
  13:10:24           ended        applied 0.1.3 β†’ 0.1.4

  ── journal Β· 2025-08-27 13:06:40 to 2025-08-27 13:11:24 UTC ──

Times are UTC, and so is the journal underneath. The + column is the gap since the line above, which is how you find the two minutes.

Reading the journal needs privileges the robot group does not carry, so the second half comes back empty unless you are root. It prints the journalctl line when that happens; sudo in front of it is the fix. --no-journal prints that line without trying, and --json gives the transcript alone.

The component is daemon β€” one component covering every binary. apply daemon installs what the stable channel offers; branch builds and release candidates need cheatsheet-dev.md.

Switching without a download

To something the board already has unpacked. No network involved:

sudo robotctl update select daemon 0.1.4
sudo robotctl update rollback daemon
sudo robotctl update reset-to-golden daemon

select activates an installed release, rollback goes to the previously installed one, and reset-to-golden goes to the never-pruned known-good one.

And refusing to move at all:

sudo robotctl update pin daemon 0.1.4
sudo robotctl update pin daemon

The second form unpins.

When updaterd itself will not start

Everything above goes through updaterd, so none of it works when updaterd is the daemon that is down. Check which one it is:

systemctl status updaterd robotd btd configd

Then go back to golden without it:

sudo robot-rescue --dry-run
sudo robot-rescue --reboot

--dry-run says what it would do and changes nothing. Without --reboot it swaps the release and prints the reboot command rather than running it: every daemon execs through current, so nothing picks up the swap until it restarts, and a robot that is standing should be caught first.

It declines, and says why, when no golden is configured or when current is already golden β€” if the daemons are failing on golden itself, a rollback is not the answer and the journal is:

journalctl -b -u robotd -u updaterd -u btd -u configd

The robot may have done this already

Three minutes into every boot, a timer asks whether the release brought its daemons up, and falls back to golden if it did not. So a robot that rebooted on its own and is running an older release than you installed has probably rescued itself. What it did:

robotctl update log

The entry reads as a rollback, with the daemon that failed named in its reason. To see the decision being made rather than its result:

journalctl -b -u robot-boot-check
sudo robot-boot-check --dry-run

It acts once. A second rescue is refused while the first is still on record β€” updaterd clears that when it next starts, so being refused means the daemons did not come up on golden either, and the answer is the journal rather than another reboot. Past it, if you have read the journal and decided:

sudo robot-rescue --force --reboot

Three things that are easy to get wrong

rollback needs a predecessor, but an update creates one. A freshly provisioned board has exactly one release, so rollback right then has nothing older to go to and says so. Auto-rollback is not affected: applying a release unpacks it alongside the current one and only then moves current, so by the time the health gate runs there are two, and the release you came from is the target. rollback_target picks the highest installed version below current that the journal has not already recorded as bad β€” so a board with one release is fully protected from the moment it takes its first update.

The one genuinely unprotected install is the bootstrap itself, which has nothing before it by definition. golden would cover that, and it is deliberately unset until 1.0.0 exists β€” so reset-to-golden reports honestly that none is configured rather than doing something surprising.

version shows the live release per component, not the release store. It will never list two versions, however many are unpacked. Ask the store directly:

ls -l /opt/robot/daemon/releases/ /opt/robot/daemon/current

apply --version needs the release to still exist upstream; select does not. Releases carrying known-bad builds get deleted from GitHub, so apply --version 0.1.3 fails on purpose, while select 0.1.3 still works on a board that already unpacked it. The asymmetry is deliberate: no new board can acquire a broken release, and a board that has one keeps its escape hatch.

Installing with no network

Sideloading, factory install, or rescuing a board whose updaterd is too old to accept the release that fixes being too old. See install-dev.md β€” it is updaterd install --from, and the --force variant has conditions worth reading before you use it.

Logs

journalctl -u configd -b --no-pager | tail -40
journalctl -u btd -f

Swap in robotd or updaterd. -f follows; -b is this boot only.

The startup line carries version, git revision and the release directory the process was launched from, at warn, so it survives any log level.

The update history is separate from the journal on purpose β€” fsynced per entry under /var/lib/robot/updater/ β€” so it survives a robot whose logs were volatile:

robotctl update log

The last twenty runs keep a full transcript there too, under runs/, written as they happened:

robotctl update show 42

Both outlive the swap, the rollback, and a power cut, which the journal on this board does not β€” /var/log is zram. If robotctl itself is what is broken, the files are newline-delimited JSON and read fine with cat:

sudo cat /var/lib/robot/updater/runs/000042.jsonl

Tab completion

install.sh sets this up in /etc/bash_completion.d/, as a loader that asks the binary for its own completions β€” so they follow the installed release instead of going stale when an update adds a command. For a shell it did not cover, or for a build you are running straight out of target/:

eval "$(robotctl completions bash)"

zsh, fish, elvish and powershell work in place of bash.