Main Computer Software¶
Process breakdown¶
The project already isolates each microcontroller so it can survive on its own if the Pi dies. This breakdown extends the same logic inside the Pi itself: processes that touch motor safety (arbiter, bus-pico, bus-xiao-nano) stay isolated from anything that can crash for reasons unrelated to safety (Wi-Fi, AI, camera, audio). One further service, power-agent, isolates in turn the only component of the system that needs elevated privileges — see Main Computer System (not written yet).
flowchart TB
subgraph external["External links — shared Wi-Fi radio"]
ctrl["Controller<br><i>Legion Go</i>"]
station["Station + AI PC"]
end
subgraph pi5["Raspberry Pi 5"]
gwctrl["gateway-controller"]
gwstation["gateway-station"]
vision["vision<br><i>CSI, single reader</i>"]
audio["audio-io<br><i>I2S mic + speakers, local sound bank</i>"]
idle["idle-behavior"]
arbiter["arbiter<br><i>command arbitration</i>"]
buspico["bus-pico<br><i>UART, 50 Hz fixed</i>"]
busxiaonano["bus-xiao-nano<br><i>shared I2C, XIAO prioritized</i>"]
poweragent["power-agent<br><i>sole privileged process</i>"]
end
subgraph mcu["Microcontrollers"]
pico["Pico 2 — legs/balance"]
xiao["XIAO RP2350 — head"]
nano["Nano Every — torso"]
end
ctrl <--> gwctrl
station <--> gwstation
vision --> gwctrl
vision --> gwstation
gwctrl -->|"camera settings (fps/quality/zoom), bypasses arbiter"| vision
gwctrl --> arbiter
gwstation --> arbiter
idle --> arbiter
arbiter --> audio
gwctrl -.->|raw audio stream, priority| audio
gwstation -.->|raw audio stream| audio
arbiter --> buspico
arbiter --> busxiaonano
busxiaonano ==>|head position, bypasses arbiter| buspico
busxiaonano -.->|power.shutdown| poweragent
busxiaonano -.->|power.shutdown| buspico
buspico <--> pico
busxiaonano <--> i2c{"I2C bus"}
i2c <--> xiao
i2c <--> nano
style arbiter fill:#FFCDD2
style buspico fill:#FFE0B2
style busxiaonano fill:#FFE0B2
style vision fill:#BBDEFB
style audio fill:#BBDEFB
style poweragent fill:#FFCDD2
The "I2C bus" node is the same physical shared bus already corrected in Torso Electronics: XIAO and Nano Every share a single bus with two addresses.
bus-xiao-nano must be a single process: two processes cannot both master the same physical I2C bus at the same time. Within this process, the XIAO is interleaved with priority; see Main Computer System (not written yet) for how the interleaving guarantees its 30 Hz contract while the Nano Every is served as filler.
arbiter decides between competing sources (Controller vs. Station vs. idle) for a given actuator group — the full arbitration model is § Command arbitration below. Decisions internal to a single data path stay local to their own process and are not meant to bubble up into arbiter: merging head pose with balance commands in bus-pico (see Motion & Balance (not written yet)), the Controller-audio priority in audio-io (see Audio design), or triggering shutdown in power-agent (see Main Computer System (not written yet)) are execution decisions specific to their own flow.
Command arbitration¶
arbiter is the single process with write access to arbiter.pico and arbiter.xiao_nano —
this section is the contract for how it turns competing source inputs into the one value each
actuator group gets on a given tick. What the robot is right now (its physical state, and
how that state changes) is a separate concern, covered in Lifecycle &
Modes — this section assumes a current state and control mode
as given, and link to that page wherever the two interact.
Actuator groups¶
Each of these is arbitrated independently of the others:
| Group | Actuators |
|---|---|
| Locomotion | Wheels, leg height |
| Head | The head's 3 axes |
| Antennas | The 2 antennas |
| Lighting | Torso LED ring (7 LEDs), eyes (future: color, blink) |
| Sound | Speaker |
Command vocabulary¶
A single message shape exists: one value per field, republished every tick. A field is interpreted by its consumer in one of two ways — this is a property of the field and the module consuming it, not a different message category:
- Level — the value itself matters, every tick (e.g. a requested speed).
- Edge — only a change in value matters; the consumer detects the transition itself (e.g. a button field that goes from 0 to 1 triggers an emote — see Lifecycle & Modes § 4).
Source staleness¶
| Source kind | Considered gone after |
|---|---|
| Internal to the robot (sensors, modules inside the Pi) | 200 ms |
| External (controller link) | 500 ms |
Past that delay with no new value, the source stops participating in its group's arbitration. The group falls back to the next source by priority (see § Group arbitration), or to a default position if no source is active.
The controller is the one exception worth calling out: it carries the robot's emergency stop, so its own staleness cannot just fall through to "next source" the way any other source's can — losing it degrades the lifecycle itself. See Lifecycle & Modes § 2.2 for the resulting state and control-mode changes.
This staleness rule does not apply to emergency stop itself (below).
Emergency stop¶
Emergency stop is a plain level, not a latch: it is engaged for as long as any source says "active", and clears the moment none do. There is nothing to release or acknowledge — the condition is simply true or false, tracked live.
- Trigger / clear — OR across every source allowed to raise it: engaged while at least one signals "active", cleared as soon as none do.
- Persistence — none beyond the level itself. The staleness rule above does not apply to this OR (a source that goes stale simply stops contributing "active" to it, the same as it would stop contributing anything else), but nothing here holds the stop engaged once every source has either cleared it or gone stale.
- Position in arbitration — a filter applied after arbitration, outside the priority table in § Group arbitration.
Control mode¶
An axis independent of the physical state (see Lifecycle & Modes). It describes which sources are allowed to participate, per group:
v1 ships with two modes, chosen to let control modes and background behaviors be tested step by step without building the full Voice/Autonomous picture up front:
| Mode | Active sources |
|---|---|
RC |
Controller only — no background behavior active |
RC_AUTO |
Controller, plus the antennas' background behavior |
gateway-station exists as a process (see § Process breakdown) and
connects to arbiter, but the Station is not an active source in either v1 mode — no mode
today authorizes it to drive a group. It stays wired but dormant until Voice/Autonomous modes
give it a role.
Voice and Autonomous modes (Controller/voice/sensors combined with background behavior on more groups) are a later step, not v1 — see Lifecycle & Modes § 8.
The antennas' background behavior's source process is not decided yet. The diagram in §
Process breakdown has an idle-behavior process (with its own
idle.cmd channel) that predates this section and may or may not end up being it — whether
background behaviors live in one general-purpose process or get split one-per-capability (so
that, say, a future camera-driven behavior doesn't share a process with simple antenna idling)
is an open question, not yet settled. Nothing here should be read as idle-behavior = "the
antennas' background behavior" until that's actually decided.
Control mode also gates emotes, the same way it gates background behaviors: each emote declares the control mode(s) it's allowed to fire in, on top of its own list of triggering sources (see Lifecycle & Modes § 5.2 and § 5.4) — a source on that list can still fail to trigger the emote if the current control mode doesn't allow it.
Changing mode. The Controller is the source allowed to change control mode, via
requested_mode in controller.cmd (see Robot ↔ Controller
Link).
Beyond filtering which sources may drive a group, control mode is also meant to eventually parameterize how they combine on summed groups (§ Group arbitration): each mode gets its own tuning of the weighted sum. Not implemented yet — v1 ships with no real blending, this is the intended direction.
Combining with physical state — for each group, a source S is drivable if and only if:
S allowed by the current physical state, for this group
AND
S allowed by the current control mode, for this group
Transition rights. A source can send a state-transition request at any time — a source
does not need to know whether it is "allowed" to transition the robot, it just sends the
request like any other command. Whether that request is honored is a separate flag per
source, checked by arbiter independently of group drivability: a source can be allowed to
drive a group's continuous value without being allowed to trigger a transition, and vice
versa. This is what actually distinguishes a reactive perception behavior from a decision-making
one (see Lifecycle & Modes § 2.1) — not where
the request comes from, but whether this flag is set for that source in the current mode. A
request from a source without transition rights is simply dropped, the same way a command on
a group the source isn't allowed to drive is dropped.
Group arbitration¶
Exclusive group — strict priority, one winner, among the sources allowed by § Control mode. There is no single global priority order: which sources can even compete on a group is already decided by the active control mode (§ Control mode above), so priority is a per-mode concern, not a fixed system-wide list. A transition or emote in progress always outranks the rest, regardless of mode — see §4, Group occupancy.
v1 (RC / RC_AUTO) never has more than one continuous source competing for a given
group at once (RC = Controller alone; RC_AUTO adds the antennas' background behavior on a
different group, not competing with the Controller on the same one), so there is nothing to
rank yet beyond transition > emote > that mode's one active source > default position. A
real multi-source priority table (e.g. once Station or sensors become active sources
alongside the Controller) is future work, to be defined per mode when it's actually needed —
see Lifecycle & Modes § 8.
Summed group (Head, Antennas, Lighting) — outside of a transition (which stays highest priority and suspends the sum), the group's value is a weighted combination of controller and background behavior. An active emote adds a temporary extra contribution on top.
| Group | Arbitration mode |
|---|---|
| Locomotion | Exclusive |
| Head | Exclusive by default, weighted sum allowed |
| Antennas | Exclusive by default, weighted sum allowed |
| Lighting | Exclusive by default, weighted sum allowed |
| Sound | Exclusive |
v1 ships with no real blending at all — closed for now, not a pending decision. This whole weighted-sum mechanism, the per-mode tuning above, the exact blending function (weighted average, median, or otherwise), and letting sensors contribute to it are a future-evolution idea, not something v1 needs to implement.
Where the final value gets clamped to mechanical limits remains open regardless of blending —
even today's plain exclusive arbitration needs it. Clamping likely belongs in the receiving
bus's firmware, the same way it already enforces per-servo range limits (see Head Firmware §
3.2), not in arbiter itself.
Background behavior — the fallback source, active by default on a group when nothing higher-priority is active. It can be generated internally (procedural motion) or informed by outside perception (e.g. a camera face-detection driving gaze) — the two are architecturally identical: both only ever contribute a continuous value to a group, never a transition request. The moment "react to what the camera sees" grows into "decide to stand up and walk toward it," it has stopped being a background behavior and become a source sending transition requests — see § Transition rights above. Which PC does the perception (Pi or Station) does not matter to this distinction.
Producer coordination¶
A producer feeding one group may read the value another group is actually sending — not a raw source, the final arbitrated value — to adjust its own behavior. Example: a background behavior on the antennas that follows the head's current commanded position.
The same mechanism is how bus-pico compensates balance for head motion: the head shifts the
robot's center of gravity, so bus-pico reads the Head group's actually-sent value and feeds
it forward into its own balance loop. This holds at all times the head moves — a standard
continuous command, an emote, or a transition — not only during a SITTING ↔ STANDING
transition; there is nothing transition-specific about it, it is just this same producer
coordination applied to Locomotion/Head. See Motion &
Balance (not written yet) for the balance-loop side of this.
This read is optional and non-blocking: a value is only ever sent once it is good; if it isn't, it is either withheld or explicitly marked degraded. Deciding what to do with a degraded or missing read — stop moving, fall back to an independent behavior, or something else — is always the consumer's call, never something the producer hides by silently substituting a value.
Groups otherwise stay arbitrated independently (§ Group arbitration): this coordination happens at the producer level, never at the arbitration level.
Not yet decided: whether a producer reads another group's value from the same tick (implying
a fixed resolution order between groups) or with one tick of lag. Either works; which one is
chosen affects how arbiter schedules its per-group resolution internally.
Internal channels¶
Same paradigm as the external links already documented (ZeroMQ), used internally over ipc://. Every channel is plain pub/sub, each on its own dedicated port — no multiplexing between channels, no additional application-level reliability beyond what the full state, republished at a fixed rate on most of them, already provides. The exact binary layout of each channel is not yet defined.
Addresses below are Unix domain sockets under ipc:///tmp/zig/ (only the file name varies per row). Two rules fix the rest: the producer binds and every consumer connects — this supports fan-out to several subscribers (telemetry.*, vision.frame) without ever reconfiguring the producer — and a socket only carries one direction, so a channel that reads as bidirectional here (audio.stream) is two sockets in practice, one per direction.
| Channel | Sender → Receiver | Content | Address | Bind / Connect |
|---|---|---|---|---|
controller.cmd |
gateway-controller → arbiter, audio-io | joystick commands, selected mode (audio-io only reads message timing, not the content — see "Audio design" below) | controller_cmd.ipc |
gateway-controller binds, arbiter and audio-io connect |
station.behavior |
gateway-station → arbiter | AI commands (head/voice/expression, locomotion if the mode allows it) | station_behavior.ipc |
gateway-station binds, arbiter connects |
idle.cmd |
idle-behavior → arbiter | idle animations | idle_cmd.ipc |
idle-behavior binds, arbiter connects |
arbiter.pico |
arbiter → bus-pico | final arbitrated command | arbiter_pico.ipc |
arbiter binds, bus-pico connects |
arbiter.xiao_nano |
arbiter → bus-xiao-nano | final arbitrated command | arbiter_xiao_nano.ipc |
arbiter binds, bus-xiao-nano connects |
arbiter.catalog |
arbiter → gateway-controller | fixed catalog of control modes, selectable states, and available emote names (see Lifecycle & Modes § 6), so the Controller UI never hardcodes the names. Published on arbiter startup, then every 60 s as a safety net for a Controller that (re)connects later — the catalog itself doesn't change at runtime, so there's nothing to push on change |
arbiter_catalog.ipc |
arbiter binds, gateway-controller connects |
xiao.head_pose |
bus-xiao-nano → bus-pico | head position, bypasses arbiter, fast path | xiao_head_pose.ipc |
bus-xiao-nano binds, bus-pico connects |
telemetry.* |
bus-* → all | telemetry republished as broadcast | one per source: telemetry_pico.ipc, telemetry_xiao_nano.ipc |
each bus-* binds its own socket, everyone else connects |
vision.cmd |
gateway-controller → vision | camera settings (fps/quality/zoom), bypasses arbiter — not a motor-safety path, same reasoning as the Controller-audio bypass below | gateway_vision_cmd.ipc |
gateway-controller binds, vision connects |
vision.frame |
vision → gateway-controller, gateway-station | camera frames, raw bytes (binary header + JPEG, no JSON — see zig-pi/CONTROLLER-PROTOCOL.md, "Flux vidéo") |
vision_frame.ipc |
vision binds, both gateways connect |
audio.trigger |
arbiter → audio-io | discrete trigger (sound from the local bank) | audio_trigger.ipc |
arbiter binds, audio-io connects |
audio.stream |
gateway-station ↔ audio-io | continuous audio stream (TTS), bypasses arbiter | audio_stream_station_tts_in.ipc + audio_stream_station_mic_out.ipc |
audio-io binds both, gateway-station connects |
audio.stream.controller |
gateway-controller → audio-io | continuous audio stream, bypasses arbiter, takes priority over the station stream while active | audio_stream_controller_in.ipc |
audio-io binds, gateway-controller connects |
power.shutdown |
bus-xiao-nano → bus-pico, power-agent (broadcast) | shutdown notification — see Main Computer System (not written yet) | power_shutdown.ipc |
bus-xiao-nano binds, bus-pico and power-agent connect |
Audio design¶
Vision has a single source (the camera) and two consumers (Controller and Station). Audio is the reverse: several sources, a single consumer (the speakers), plus a separate incoming stream (the microphone).
flowchart LR
bank["Local sound bank<br><i>stored on the Pi</i>"]
station["Station audio stream<br><i>e.g. TTS</i>"]
ctrltrig["Sound trigger<br><i>Controller → local bank</i>"]
ctrlstream["Raw audio stream<br><i>Controller</i>"]
audio["audio-io"]
speakers["Speakers (I2S)"]
mic["Microphone (I2S)"]
bank --> audio
station -.->|continuous stream| audio
ctrltrig -->|via arbiter| audio
ctrlstream ==>|continuous stream, priority| audio
audio --> speakers
mic --> audio
audio -.->|continuous stream| station
audio-io owns the local sound bank and the I2S peripheral. Discrete triggers ("play sound X") go through arbiter like any other command. Continuous audio streams (TTS streamed from the Station, or a raw stream pushed by the Controller) do not go through arbiter — they go directly to audio-io.
The Controller can both trigger a sound from the local bank and push a raw audio stream. In either case, audio coming from the Controller has priority: it temporarily replaces the Station stream for as long as it stays active.
System-status sounds (see zig-pi/ARCHITECTURE.md §10)¶
Three sounds aren't commanded by Controller, Station, or idle, so they skip arbiter/audio.trigger — audio-io triggers them itself:
- Startup — played directly when
audio-io's process starts. - Controller link connected/lost —
audio-iosubscribes tocontroller.cmdand derives connected/lost from message timing, the same watchdog patternbus-pico/bus-xiao-nanouse to disarm on silence. - Low pack voltage —
audio-iosubscribes totelemetry_xiao_nano.ipcand triggers once the pack voltage stays under threshold for 5 s.
No new channels: both are existing ones audio-io subscribes to, following the same "producer binds, consumers connect" rule as any other listener.