Robot ↔ Controller Link¶
Transport¶
Wi-Fi, ZeroMQ. The Pi binds as server on every port below; the Controller connects as client — the same convention used on the Station & AI PC link. One port per data stream: video, audio and commands never share a socket, since mixing large video frames with small, latency-sensitive command messages on the same PUB/SUB socket would let one starve the other. The same rule is applied one level down, between the Pi's own internal processes — see Main Computer Software.
Ports¶
| Port | Channel | Direction | Content |
|---|---|---|---|
| 6000 | controller.cmd |
Controller → Pi | Unified command: locomotion + head + global armed/estop |
| 6001 | vision.frame |
Pi → Controller | Camera feed, JPEG per frame (MJPEG), raw bytes (binary header + JPEG, no JSON) |
| 6002 | audio out | Pi → Controller | Robot audio monitor |
| 6003 | audio in | Controller → Pi | Microphone / push-to-talk |
| 6004 | telemetry (Frame 2) | Pi → Controller | Robot state, control mode, battery, sensors |
| 6005 | catalog | Pi → Controller | Fixed list of selectable states and control modes |
Command path: gateway-controller binds 6000, forwards whatever arrives unparsed onto the internal controller.cmd channel, and arbiter splits it from there toward the legs and the head. State path: gateway-controller also subscribes to the internal telemetry_pico/telemetry_xiao_nano channels, aggregates the latest known value from each into Frame 2, and publishes it on 6004 whenever either source updates — see Main Computer Software for the full path both ways. Catalog path: gateway-controller subscribes to the internal arbiter.catalog channel and forwards it as-is on 6005 — see Lifecycle & Modes § 6 for what it contains and how often it's sent. This is why the state names in Frame 2 and the mode/state names the UI offers are never hardcoded in the Controller: it reads them from 6005 instead.
Data protocol (controller.cmd)¶
Scope¶
Motor control, robot state, and actuators (LEDs, vibrator...) — carried by Frame 1 on port 6000 (controller.cmd) and Frame 2 on port 6004, as listed in the Ports table above. Video (6001) and audio (6002-6003) are out of scope here.
Why this shape¶
The message is organized by physical module of the robot (legs/balance, head, torso), with explicit field names instead of implicit positions — "servo": [512, 512, 585, 540, 485] would only make sense if you already know the positional order of the underlying I2C frame, and that's not the Controller's job to know: whether the head speaks I2C, UART or something else is none of its business.
armed/estop stay outside the sub-parts, at the root level: those are global fields, applied identically to the whole robot to avoid an accidental partial disarm. The per-module structure doesn't change that — it only concerns data specific to each actuator.
The frame content evolves as new modules, sensors, and actuators are added; this description reflects the design at the time of writing and may no longer be complete.
Partially superseded by Lifecycle & Modes
armed, estop, balance, and the leg PID gains below are still what's actually in use
today (the PID gains, in particular, are currently tuned and stored on the Controller
side) and stay as they are for now. robot_state in Frame 2, however, predates Lifecycle
& Modes and is replaced below by the real state
names from that graph. requested_state/requested_mode/emote_trigger are new, added to
close the gap: Frame 1 had no way to ask for a state change, a control-mode change, or an
emote before now, only raw speeds/positions.
Frame 1: Command (Controller → Pi) — port 6000¶
Structure¶
{
"armed": true,
"estop": false,
"requested_state": null,
"requested_mode": null,
"emote_trigger": null,
"legs": {
"v": 0.5,
"yaw": -0.2,
"height": 0.7,
"armed": true,
"balance": true,
"estop": false,
"tilt_setpoint_deg": 1.50,
"kp": 0.55,
"ki": 0.00,
"kd": 0.03,
"kvel_p": 6.5,
"kv_outer": 2.0,
"kyaw_p": 0.1,
"kyaw_i": 0.3,
"motor_balance": 1.0,
"yaw_trim": 0.0
},
"head": {
"antenna_right": 512,
"antenna_left": 512,
"neck_1": 585,
"neck_2": 540,
"neck_3": 485,
"eye_mode": 1,
"vibrator": 0
},
"torso": {}
}
All sub-parts are optional, both at message level and at field level: {"head": {"eye_mode": 2}} alone is a valid message, everything else keeps its last known value (partial update). An absent section is not commanded at all — it is not an implicit {}.
Global fields¶
| Field | Type | Description |
|---|---|---|
armed |
bool | Enables motor actuation, whole robot. A single armed for the whole robot — no per-module armed. |
estop |
bool | This source's emergency-stop signal, whole robot. arbiter ORs it with every other source allowed to raise it — see Main Computer Software § Emergency stop. It's a plain level: false here actually clears this source's contribution, it doesn't need a separate "release". |
requested_state |
string or null |
The state this source wants (e.g. "STANDING"), using the exact names from Lifecycle & Modes § 1.1. null (or the field absent) means no request this frame — same partial-update rule as everything else here. Whether it's honored depends on transition rights (see Main Computer Software § Control mode); an unauthorized or unreachable request is dropped, not errored. |
requested_mode |
string or null |
The control mode this source wants — one of the exact names in the catalog (port 6005; v1 ships RC and RC_AUTO, see Main Computer Software § Control mode). null means no request. The Controller is the source allowed to change mode. |
emote_trigger |
string or null |
Name of the emote to trigger this frame (e.g. "content_petting"), one of the exact names in the catalog (port 6005 — see Lifecycle & Modes § 5.4). null means no trigger. This is an edge field (see Main Computer Software § Command vocabulary): arbiter reacts to it changing to a name, not to it merely being non-null every frame — a name repeated across frames while the Controller keeps sending it (e.g. a held button) does not re-trigger the emote. |
legs — legs and balance (→ Pico 2)¶
| JSON key | Type | Default | Range / description |
|---|---|---|---|
v |
float | 0.0 | [-1..1] normalized forward/backward speed (v_target) |
yaw |
float | 0.0 | [-1..1] normalized rotation speed (yaw_rate_target) |
height |
float | 0.4 | [0..1] normalized leg height (height_cmd) |
armed |
bool | false | allows motor actuation |
balance |
bool | false | enables the balance loop |
estop |
bool | false | emergency stop |
tilt_setpoint_deg |
float | 1.50 | tilt target angle, in degrees |
kp |
float | 0.55 | tilt PID proportional gain |
ki |
float | 0.00 | tilt PID integral gain |
kd |
float | 0.03 | tilt PID derivative gain |
kvel_p |
float | 6.5 | damping proportional to instantaneous speed (tune first, with kv_outer=0) |
kv_outer |
float | 2.0 | pull-back toward the starting position, proportional to drift |
kyaw_p |
float | 0.1 | damps the wheel speed difference (unwanted rotation) |
kyaw_i |
float | 0.3 | heading hold, pulls back on cumulative rotation |
motor_balance |
float | 1.0 | left/right motor power balance |
yaw_trim |
float | 0.0 | constant rotation offset |
head — head (→ XIAO RP2350)¶
| Field | Type | Range | Description |
|---|---|---|---|
antenna_right |
int | 0-1023 |
Right antenna position (512 = vertical) — see Head Electronics |
antenna_left |
int | 0-1023 |
Left antenna position |
neck_1 |
int | 490-680 (clamped in firmware) |
Neck servo 1 |
neck_2 |
int | 420-570 (clamped in firmware) |
Pitch — min = head down, chin down |
neck_3 |
int | 297-673 (clamped in firmware) |
Neck rotation, ±55° |
eye_mode |
int | 0-255 |
LED mode (0=Off, 1=On, 2=Blink, 3=Expression) — see Pi ↔ Xiao bus |
vibrator |
int | 0-255 |
Vibrator intensity |
Raw 0-1023 values, unlike the normalized legs fields: these are directly the documented servo positions, already clamped by the firmware.
torso — torso (→ Arduino Nano Every)¶
Reserved for fan, LED matrix, sensors, and power telemetry. Section absent from the message until defined — the example above just shows the reserved slot, not a {} to actually send.
Frame 2: State (Pi → Controller) — port 6004¶
Structure¶
{
"state": "SITTING",
"control_mode": "RC",
"legs": {
"sequence": 42,
"torso_inclination": {"x": 0.1, "y": 0.0, "z": 0.0},
"forward_speed": 0.3,
"rotation_speed": 0.0,
"height": 0.4,
"status_flags": 0,
"wheel_speed": {"left": 0.31, "right": 0.29}
},
"head": {
"sequence_echo": 42,
"touch": {"left": false, "right": false},
"distance_cm": 0,
"imu": {
"accel": {"x": 0, "y": 0, "z": 0},
"gyro": {"x": 0, "y": 0, "z": 0}
},
"status": "ok"
},
"torso": {
"battery": 0.8
}
}
As with Frame 1, a sub-part only appears once its source has published at least one message — no {} and no invented zeros for a source that hasn't said anything yet.
Global fields¶
| Field | Type | Description |
|---|---|---|
state |
string | Current physical state, one of the exact names in Lifecycle & Modes § 1.1 (SAFE, SITTING, STANDING, WALKING, RUNNING, FALLING, FAULT, ESTOP, SHUTDOWN). Reflects the whole robot's state, not just the legs. The names the UI should offer as selectable targets come from the catalog (port 6005), not from a list hardcoded against this field. |
control_mode |
string | Current control mode, one of the exact names in the catalog (port 6005; v1 ships RC and RC_AUTO, see Main Computer Software § Control mode). |
legs — from the Pico telemetry (10 Hz)¶
| Field | Type | Description |
|---|---|---|
sequence |
int | Frame counter, to detect loss/latency |
torso_inclination |
{x, y, z} |
Torso inclination |
forward_speed |
float | Measured forward/backward speed |
rotation_speed |
float | Measured rotation speed |
height |
float | Measured height (echo of the commanded setpoint, not an independent measurement) |
status_flags |
int | Controller/hardware status flags: bit0=armed, bit1=balance_enabled, bit2=emergency_stop, bit3=motors_active |
wheel_speed |
{left, right} |
Measured wheel speeds |
head — from the XIAO telemetry (10-30 Hz)¶
| Field | Type | Description |
|---|---|---|
sequence_echo |
int | Echo of the last sequence sent, to detect loss/latency |
touch |
{left, right} |
Touch state |
distance_cm |
int | Measured distance |
imu |
{accel: {x,y,z}, gyro: {x,y,z}} |
Raw IMU |
status |
string | ok / error (reflects the 200 ms watchdog failsafe on the XIAO side) |
torso (Arduino Nano Every)¶
| Field | Type | Range | Description |
|---|---|---|---|
battery |
float | 0..1 |
Battery charge, normalized |
Absent from the message until defined — same reserved-slot convention as Frame 1.