Skip to content

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.