Controller Software¶
Stack¶
- Legion Go: physical buttons/sticks plus a touchscreen — the interface has to work with both input methods. Its screen is 1200×800, landscape, fixed — the app's canvas matches it exactly and isn't meant to scale to anything else (see Interface & Screens).
- PySide6 for the graphical, touch-driven interface.
- pygame, to read the physical gamepad. No pygame window is ever created — pygame and Qt each want to own a window and an event loop, and that conflict is what's avoided.
pygame.display.init()is still called with no window (confirmed empirically:pygame.event.pump(), needed for SDL to notice controller attach/detach and refresh axis/button state, raises "video system not initialized" without it) — a detail, not a reversal of the no-window decision. - ZeroMQ (
pyzmq), used uniformly for two different things: the Wi-Fi link to the Pi, and the internal communication between the app's own threads (overinproc://instead oftcp://). Same library, same discipline, in both places.
Threads, not a single loop¶
Each concern runs in its own thread: talking to the Pi, reading the physical joystick, and the Qt GUI. The pieces are kept independent enough that ZMQ ties them together almost the way separate processes would — a deliberate choice, since it keeps the door open to actually splitting any of them into a separate process later without a rewrite.
Every ZMQ socket used here, external (Wi-Fi link) or internal, follows the same rule the current test rig already applies to the Wi-Fi link (zmq.CONFLATE): always the latest value, never a queued backlog. No message here is a one-shot event — each one carries the full current state, so replacing a stale queued message with a fresher one loses nothing.
Communication architecture¶
Link module (controller/link/pi_link.py, written) — owns the actual sockets to the Pi: a command-sender thread (Frame 1, port 6000), a telemetry-receiver thread (Frame 2, port 6004), and a video-receiver thread (port 6001) — see Robot ↔ Controller Link for the full port table. This mirrors what manette-tester/tester.py already does today in a single Tk polling loop, just split one thread per stream instead of one shared tick.
The link module republishes what it receives locally, over inproc:// PUB sockets with CONFLATE, one topic per data type: telemetry, video frame, connection state (controller/common/ports.py names the topics). Smoke-tested against a fake local Pi (a script binding the same ports and feeding it fake Frame 2 / video bytes): command sender delivers a well-formed, correctly-defaulted Frame 1 and picks up state changes; telemetry and video are both republished locally unchanged; link state correctly flips to connected once telemetry arrives.
The outgoing state is a typed CommandState (controller/common/models.py), not a loose dict — mirrors the field names/defaults/ranges in zig-pi/CONTROLLER-PROTOCOL.md. Incoming Frame 2 goes through Telemetry.from_frame2(), which only fills in the sections actually present (same partial-update rule as the wire protocol) and decodes legs.status_flags into named booleans (.armed, .balance, .estop, .motors_active).
Screens subscribe locally, not to the Pi directly. Any screen or widget that needs one of these opens its own local SUB socket on the topic it needs, and polls it (non-blocking) from a QTimer running on the Qt GUI thread. This is deliberate: Qt widgets can't be touched safely from a non-GUI thread, and polling from a GUI-thread timer avoids wiring a Qt signal/slot by hand between every source and every consumer — a widget just opens a socket for the topic it cares about. In practice today: screens/main_screen.py subscribes to TOPIC_LINK_STATE (and starts the TOPIC_VISION_FRAME video feed); widgets/chrome_overlay.py subscribes to TOPIC_GAMEPAD_STATE and TOPIC_TELEMETRY; widgets/head_popup.py and common/telemetry_history.py each open their own independent TOPIC_TELEMETRY subscription for what they need (head distance, legs history) rather than sharing chrome's.
Topics are published whether or not anything is subscribed. Adding a future screen (Balance tuning, Diagnostics, Torso — see Interface & Screens) only means adding a new local SUB socket on an existing topic — no change to the link module, and no change to the Pi-facing protocol.
The persistent chrome (banner, Menu button, Emergency-stop buttons — see Interface & Screens) follows the same rule, built once as its own shared instance (widgets/chrome_overlay.py) rather than living inside main_screen.py: its telemetry-driven parts (MOTORS ACTIVE chip, robot battery level) defer connecting TOPIC_TELEMETRY until a PiLink exists, exactly like MainScreen's own deferred connect described above — not a new pattern, just one more thing built against it. ARMED/DISARMED and the BALANCE chip are commanded, not telemetry-driven, so they don't need that deferred connect at all — see Interface & Screens for the ARMED/BALANCE distinction.
Joystick thread (controller/gamepad/joystick_thread.py, written) — polls pygame.joystick at 30 Hz and republishes the mapped state (see Joystick mapping below) on TOPIC_GAMEPAD_STATE, same CONFLATE rule as everything else. Handles no-controller-attached and mid-session disconnect gracefully (retries every second, reports connected: false rather than raising) — tested without a physical gamepad, which is the actual state of this dev environment, so that specific path is the one confirmed against reality; the with-a-real-controller path is not.
Ordering requirement, confirmed the hard way: JoystickThread must be constructed (which binds TOPIC_GAMEPAD_STATE) before PiLink.start() is called. inproc:// connect() before bind() doesn't raise an error, but was observed during testing to silently drop every message — not a theoretical footgun, an actual repro. Whatever assembles the app has to respect this order. app.py does: JoystickThread first, then MainScreen/ChromeOverlay/every other screen and popup (ChromeOverlay's own TOPIC_GAMEPAD_STATE subscription is therefore safe at construction time), and only later — once the user submits the Connection screen — a PiLink, which is when MainScreen opens TOPIC_LINK_STATE and starts the TOPIC_VISION_FRAME video feed (see screens/main_screen.py's set_link()), and ChromeOverlay/widgets/head_popup.py/common/telemetry_history.py each open their own TOPIC_TELEMETRY subscription — specifically so none of these connect before PiLink has bound them.
This ordering rule turned out to be stronger than "silently drops": building the Qt Main screen's test suite hit a case where a SUB socket connect()-ed to an inproc:// address that nothing had bound yet, then got close()-d before any matching bind() ever happened — and that alone left the process's zmq.Context in a state where a later bind() of that same address (or an explicit ctx.term() on that context) aborts the whole process with a native libzmq assertion (Assertion failed: ok, ctx.cpp:789), not a Python exception. Confirmed as a minimal, deterministic repro, independent of Qt/pygame — pure pyzmq. Practical upshot: the bind-before-connect rule isn't just about not losing messages, it can crash the process outright if a stale connect-before-bind socket is ever closed and its address reused. controller/conftest.py's zmq_ctx fixture and every test fixture that stands in for PiLink/JoystickThread bind all four topics before constructing anything that connects to them, for exactly this reason.
Building the outgoing Frame 1 — the command-sender thread inside the link module is the single point that merges the joystick topic and touch/UI input coming from Qt widgets into one outgoing frame, through PiLink.set_armed() / .set_estop() / .update_legs() / .update_head() / .update_torso() / .update_vision(). Same principle as the single State object in manette-tester/tester.py today, just fed from two input sources (joystick + touch) instead of one (keyboard + on-screen controls) — written and tested (fake GamepadState in, merged legs/head fields out; a disconnected gamepad leaves the last touch/UI-set values alone rather than overwriting them with zeros).
The discussed-but-not-built physical input lock would gate this same merge point: while locked, the joystick's contribution would be skipped here, the same way a disconnected gamepad already is, leaving whatever touch/UI values were last set untouched.
Joystick mapping¶
(controller/gamepad/mapping.py, unit-tested — pure functions, no pygame dependency, so this part is tested independent of any hardware.)
| Input | Command | Notes |
|---|---|---|
| Left stick, Y axis | legs.v (forward/back) |
Stick up → forward |
| Left stick, X axis | legs.yaw (turn in place) |
Combines with v for a diagonal move — ordinary two-independent-axes behavior, no extra mixing logic needed |
| Right stick, Y axis | head.neck_2 (pitch) |
Stick up → head up |
| Right stick, X axis | head.neck_3 (rotation) |
|
| Triggers (differential) | head.neck_1 (tilt/roll) |
Right trigger tilts right, left tilts left, neither or both → flat |
All three neck axes map proportionally from their documented center, not the arithmetic midpoint of their range — neck_2's center (540) sits off-midpoint in its 420-570 range, matching the center manette-tester/tester.py and the XIAO firmware already use (HEAD_CENTERS / SAFE_SERVO_CENTERS), so a resting stick lands the head exactly where "recenter" already puts it. neck_1 and neck_3 happen to be symmetric (center = arithmetic midpoint) but use the same code path, not a special case.
Unverified against real hardware (no Legion Go controller available while writing this):
- Axis/button indices (
AXIS_LEFT_X,BUTTON_Y, etc.) — pygame's numbering depends on the controller's SDL mapping, which varies by OS and controller model. - Trigger axis convention — assumed
-1(released) to1(pressed), the common SDL/XInput convention; some controllers report0..1directly instead. - Which trigger tilts the head which way —
neck_1has no named axis inhead_and_neck/electronics.md(unlike the other servos), so "right trigger → tilt right" is this session's assumption, not a confirmed mapping.
All three are isolated in gamepad/joystick_thread.py as named constants / one conversion function specifically so they're cheap to correct once real hardware is available.
Round-trip latency — not on the wire yet¶
What exists today (and what manette-tester/tester.py already displays) is freshness: the age since the last Frame 2 message was received, measured against the Controller's own local clock. That is not the same as round-trip latency.
The sequence/sequence_echo fields already on the wire don't help here either — they're internal to the Pi (Pi ↔ XIAO link health), not something the Controller sends out and gets back. A true round-trip figure would need the Controller to stamp its own outgoing Frame 1 with a sequence or timestamp, and the Pi to echo it back in Frame 2 — a small protocol addition that hasn't been made yet.
Decided: until that field exists, the persistent chrome shows the shared "no data" placeholder for latency (see No-data convention below) rather than substituting freshness in as a stand-in — freshness is real data, but it isn't round-trip latency, and displaying it under that label would look like a reading it isn't. See Interface & Screens. Latency showed ∞ in an earlier pass; that's been replaced by the same None/gray "--" treatment used everywhere else, so it reads as "not measurable" rather than as a real, maximally bad measurement.
No-data convention¶
Several fields are not simply absent — they're present on the wire but known to be fake: robot_state (always "unknown"), torso_inclination.y/.z, head.imu.*, head.distance_cm are all firmware placeholders, not real readings (see Data & Controls for the full list). That's a different case from a section that hasn't arrived at all yet (e.g. torso before the Nano Every exists, already modeled as None on Telemetry.torso in common/models.py), and different again from the Wi-Fi link being down entirely (handled today by link_lost_overlay.py, not per-field).
One convention covers both "never a real reading" and "no reading yet": represent both as None at the point closest to the data model, and render None the same way regardless of which of the two it is. Implemented as a small shared module, widgets/no_data.py (format_value/banded_color), rather than reinventing the same if value is None check per widget:
- Text / numeric readout:
None→ a neutral gray "--" (format_value).widgets/battery.py(battery_text) andwidgets/latency.py(via the chrome's latency chip) both go through this. - Gauge / chip / colored indicator:
None→ the same neutral gray background (banded_color), distinct from every real color band —widgets/battery.pyandwidgets/latency.pyagain, plus the chrome's MOTORS ACTIVE chip. - Rolling chart: a flat line at 0 would misrepresent a firmware placeholder as a real, stable reading. A field that is permanently fake (
torso_inclination.y/.z) gets no chart at all, rather than an empty one that will never receive a point — see the Balance tuning page. A field that is only transiently absent (e.g.legshasn't arrived yet right after connecting) gets its chart frame drawn, with points skipped rather than interpolated or clamped, until real samples start arriving (widgets/rolling_chart.py'ssplit_into_segments).
widgets/latency.py used to be the one display that didn't follow this (math.inf + red, modeling "the field doesn't exist on the wire" as if it were a real, maximally bad measurement) — aligned onto None + gray now, along with everything else above.
Battery percentage estimation¶
widgets/battery.py only handles display banding and "no data" formatting (see No-data convention above) — it does not compute the percentage itself, it receives Telemetry.torso.battery as an already-computed 0-100 float.
That upstream computation used to be robot-side: _battery_percent(), a 2-point linear interpolation of pack_voltage between 9.6 V (0%) and 12.6 V (100%), matching the software/hardware cutoff thresholds documented in power.md. The file it lived in (zig-pi/common/controller_protocol.py) no longer exists in the repo — lost during the zig-pi restructuring. Beyond being lost, the approach had a real flaw independent of the 2-point linearity: it read the loaded pack voltage directly, with no compensation for the current draw at that instant. LiPo voltage sags under load, so any significant current (legs walking, balance-loop correction) pulls the reading down well below the true state of charge — observed dropping from 45% to 0% in under two minutes while the robot was walking and balancing, consistent with load-induced voltage sag rather than the pack actually running out of energy that fast.
First iteration, Controller-side, display only: rather than restoring/fixing the robot-side computation — which would mean touching Nano Every firmware — the improved estimate is computed in the Controller instead, from the raw pack_voltage it already receives over Frame 2 (torso.pack_voltage, see Data & Controls). Faster to iterate and debug without a firmware round-trip. This pass replaces the flawed 2-point straight line with a proper multi-point OCV (open-circuit voltage) curve for a 3S LiPo pack — still reads the loaded voltage as-is, so it does not yet correct for sag under load; that's the next step, not this one.
Deferred, not yet implemented:
- IR (internal resistance) compensation using
bus_current(from the INA226 — shunt resistor and I2C address still "to define" robot-side per power.md) to estimate the true open-circuit voltage instead of the loaded one, before mapping it through the OCV curve. - Coulomb counting against the pack's rated 1300 mAh capacity (see battery.md), periodically recalibrated against the IR-compensated OCV, so the displayed percentage holds steady under load instead of tracking instantaneous voltage.
Settings persistence¶
Implemented for the Balance tuning page (common/balance_presets.py): every gain value applied there is written immediately to controller/config/balance_gains.json, so a restart never loses a tuning session. That file is committed to git rather than ignored — the Controller runs from the same repository checkout on more than one machine (a development PC and the Legion Go), and this is how the two stay in sync without a manual copy step.
The Connection screen's recent-hosts list (common/connection_history.py, controller/config/connection_history.json) uses the same JSON-file shape but the opposite git decision: ignored, not committed — which IPs get used is per-machine, not something to sync.
Not yet decided¶
- Whether/when to add the round-trip latency field described above.
- Exact automatic IP-discovery mechanism for the Connection screen (see Interface & Screens) — manual IP entry and an automatic search were both proposed, not which one is the default or whether both coexist.
- What happens if one popup's trigger is pressed while another popup is already open — Interface & Screens now has an implemented default (close the first, open the second) but that's this session's pick to unblock the code, not a confirmed product decision.