Skip to content

Interface & Screens

Connection, Main, Menu, the persistent chrome, and Balance tuning are implemented; not yet run against a real Pi/robot or a real Legion Go

Built and covered by automated tests: Connection, Main, Menu, the persistent chrome (its own file, widgets/chrome_overlay.py, not part of any single screen), the Balance tuning page, and the Torso/Head/Mode popups. Only Main was designed in pixel-level detail before implementation; everywhere this page left something unsettled, the implementation's pick is flagged inline below as this session's pick, not a settled decision.

Still not built: the Diagnostics Menu page, the Sound popup's content, the physical input lock, and eye mode / antenna mode / head mode within the Head popup (see that section).

See Controller Software for the app's stack and internal communication, and that page's "Not yet decided" for what's still genuinely open.

A star topology: the Menu screen is the central hub, reachable from every other screen, and all navigation between non-menu screens goes through it. The one exception is the Connection screen, which goes straight to the Main screen once a link is established — it doesn't route through the Menu.

The persistent chrome (banner, Menu button, Emergency-stop buttons) sits above this topology rather than being part of it: it's shown identically no matter which screen the stack currently holds, not a screen of its own.

Persistent chrome

The top banner, the Menu button, and both Emergency-stop buttons are not screen-specific: they exist as a single instance, owned by the application, positioned identically wherever they appear — not duplicated per screen. This matters most for the Emergency-stop buttons: a button whose position could vary from one screen to the next would defeat the point of an emergency control meant to be found by muscle memory.

This chrome is shown, unchanged, on every screen once a link exists — Main, Menu, and every Menu page. It is not shown on the Connection screen, since there is nothing to arm or stop before a robot is reachable.

Left to right: joystick connection icon, latency, armed/disarmed status, BALANCE and MOTORS_ACTIVE state — then the clock, centered — then, on the right, a gamepad icon identifying the Controller's own battery level, and a robot icon identifying the robot's battery level. Every element in the banner is the same height. The joystick connection icon and the two battery-identifying icons are square logos (placeholder glyphs for now — 🎮/🤖 — pending real icon art); the rest are text boxes.

Element Source Notes
Joystick connection icon local, pygame.joystick Whether the physical gamepad is detected — not the Wi-Fi link to the Pi (see Link loss below for that)
Latency — Shows ∞ for now — see Round-trip latency
Armed/disarmed status — Reflects the Controller's own local, last-commanded state (what the app believes it just sent), not the robot-confirmed legs.status_flags bit from Frame 2 — implemented as the gamepad hold-toggle's own state (TOPIC_GAMEPAD_STATE's armed field), since the physical Y hold is currently the only way to arm/disarm at all
BALANCE chip Local, last-commanded state Same convention as ARMED/DISARMED, not robot-confirmed — set from the Balance tuning page's own toggle (BalanceScreen.balance_changed), not from telemetry. Deliberately not sourced from legs.status_flags: that bit lags behind what was actually just commanded, which read as unresponsive
MOTORS ACTIVE chip Frame 2, legs.status_flags Robot-confirmed motor actuation — no commanded equivalent exists (nothing sends it), so this one does stay telemetry-sourced, gray until legs telemetry has arrived at least once
Clock local Format (HH:MM:SS) is this session's pick, not specified
Gamepad icon — Identifies the battery reading to its right as the Controller's, not the robot's
Controller (Legion Go) battery level local, Windows Not carried by the Pi link
Robot icon — Identifies the battery reading to its right as the robot's
Robot battery level Frame 2, torso.battery Not available yet — Nano Every not built (see zig-pi/CONTROLLER-PROTOCOL.md, "torso")

Both battery indicators (robot and Controller) show the percentage as text on the colored icon itself, colored by level:

Level Color
≥ 70% Dark green
50-69% Light green
20-49% Orange
< 20% Red

The diagram shows one illustrative value per battery (80% / 45%) so the two color bands are distinguishable at a glance — not a claim about the actual charge level.

main_screen.svg only pins an exact hex for two of the four bands (dark green #008a00 and orange #ff9800, matching its 80%/45% illustrative values) — light green and red have no hex in the source, controller/widgets/battery.py picks #8bc34a/#e53935. No reading yet (robot battery, since torso.battery isn't on the wire) shows as a neutral gray "--", the same "no data" state described in Controller Software — a fifth state not in this table, distinct from the red "critically low" band. MOTORS ACTIVE follows the same convention until legs telemetry has arrived at least once; BALANCE never shows that state, since it's a commanded value, always known (see the banner table above).

  • Menu button — navigates to the Menu screen (a no-op if already there).
  • Emergency-stop button, ×2 — not momentary: pressing either one engages estop (PiLink.set_estop(True)) and swaps both buttons' colors (red-on-white becomes white-on-red) to show it's active; pressing either one again releases it (set_estop(False)). Both buttons are one logical control and stay in sync. This resolves what was previously an open question (how estop gets cleared) — decided: the same button toggles it, not a separate control.

Arming

Hold the physical Y button for 3 seconds to arm, and the same — hold Y for 3 seconds — to disarm. One button, one gesture, both directions. (An earlier idea used a DJI-style stick gesture instead; dropped as impractical to actually use.)

Physical input lock

Discussed but not built: holding a physical button (e.g. X — an example, not a fixed choice) for about 2 seconds toggles whether the physical sticks/buttons are honored at all, same hold-then-flip mechanism as arming, on a different button. A small icon appears in the banner while locked; holding the same button for the same duration again lifts the lock. This is independent of which screen is showing — navigating to a different screen never disables or re-enables physical input on its own, only this explicit gesture does.

Screens

Connection screen

Lets the user reach the robot either through automatic IP discovery or manual IP entry. Which mechanism is used for automatic discovery isn't decided yet (see Controller Software) — implemented so far: manual entry only, nothing invented for the other path (controller/screens/connection_screen.py). The IP field is read-only and opens the touch numeric keypad (widgets/numeric_keypad.py, "text" mode — see Balance tuning page) instead of accepting typed input directly, since there's no physical keyboard on the Legion Go. Below the field, up to 5 recently used distinct hosts are offered as quick-select buttons (common/connection_history.py, most recent first) so a previously used address doesn't need retyping through the keypad — local to the machine, not committed to git (unlike the Balance tuning gains), since which IPs get used is different on the dev PC and on the Legion Go. Input validation is deliberately minimal (non-empty after trimming whitespace, nothing IP-format-specific) since nothing stricter is decided either. On success, goes straight to the Main screen (see Navigation model) — "success" here just means a host was entered; there's no handshake before navigating, so the Main screen can land on a link that isn't actually up yet (see Link loss, which covers that case regardless of why the link is down).

The field, the Connect button, and the recent-host buttons are all a fixed, narrow width rather than stretched across the full 1200px canvas — feedback: a single centered control on an otherwise empty screen shouldn't span the full width just because the layout would stretch it there by default. Same reasoning applied to the Menu screen's buttons.

Main screen

The only screen designed in detail so far — see below.

The navigation hub: from here the user reaches any other screen. Implemented (controller/screens/menu_screen.py): title, a "Balance tuning" button (wired to the built page), a "Back to Main" button, and a "Quit" button. Diagnostics has no button yet — no screen exists to link it to (see Menu pages).

Two pages beyond Main are named with their scope decided (see Data & Controls for the field-by-field assignment):

  • Balance tuning — legs/balance PID gains, tilt setpoint, height, and the telemetry needed to judge them (torso pitch, wheel speed, forward/rotation speed, balance/motors-active flags). The first one designed in detail, and built — see below.
  • Diagnostics — system and sensor health that matters for debugging, not for driving: Pi 5 temperature, Pico/XIAO/Nano status, raw head IMU/distance/servo echoes, torso pack voltage/current/power-switch state/touch/shutdown cause, Controller Wi-Fi quality. Named, scope defined, not designed in detail yet.

This retires the earlier "Data" and "Legs values" placeholder names: their content is fully absorbed into Diagnostics and Balance tuning respectively, and building them as separate pages would just duplicate the same telemetry rows without a reason of their own to exist. Two toggles floated as possible pages didn't get one either — a page for two switches isn't worth building on its own: AI enable/disable isn't decided at all yet, and Sound enable/disable more likely folds into the Sound or Mode popup (see Popups) than into a Menu page.

Torso content (fan speed, LED ring, button illumination) was originally scoped here too, as a "Torso" page — moved to a Torso popup instead: these turned out to be controls touched while interacting with the robot (mood lighting, a quick fan bump), not deliberate configuration done with the robot stationary, so the popup profile fit better than a Menu page.

A generic Debug page — a catch-all for fields that don't yet justify their own destination (e.g. raw sequence counters, anything named later) — has been discussed as a possible future home for whatever doesn't fit above, but isn't committed to and isn't scoped.

Adding any of these needs no change to the link module or the Pi-facing protocol — see Controller Software.

Main screen layout

The camera feed (vision.frame, port 6001 — see Robot ↔ Controller Link and zig-pi/CONTROLLER-PROTOCOL.md, "Flux vidéo") fills the entire page as a background; every other element described below is layered on top of it, underneath the persistent chrome. All on-screen interface text is in English.

Main screen layout draft: full-bleed video, top banner (latency, gamepad status, clock, armed/disarmed status, battery levels), Menu and Emergency-stop buttons on both top corners, the vision parameter slider and cycle button bottom-left, and the Sound and Mode buttons bottom-right

Draft, to scale. Source is a draw.io diagram (main_screen.svg, edit with draw.io only — the file embeds its own editable source) — it predates the chrome being pulled out as a shared, cross-screen element (see the warning at the top of this page), so it still draws the banner and Emergency-stop buttons as part of Main's own canvas.

Canvas

1200×800, landscape, is not an arbitrary implementation choice: it is the Legion Go's own screen resolution, fixed and unchangeable. There is no scaling question to resolve here — this settles what Controller Software previously listed as undecided. Every screen in the app is built against this exact canvas; MainWindow itself is a fixed-size window at this resolution (app.py), not just an internal layout choice inside MainScreen.

Bottom-left (over the video)

One slider plus a button to its right, both the same height (40px to start with). The button shows which of the three vision parameters the slider currently controls; pressing it cycles to the next one, changing both the button's label and the slider's range: Zoom → FPS → Quality, then back to Zoom. The slider shows the current value as text (e.g. "1.8x") — the button's job is only to say which parameter is active, not what it's set to, so its label stays short and stable across a drag.

Parameter Slider range Source
Zoom 1.0-3.0 Frame 1, vision.zoom
FPS 10-30 Frame 1, vision.fps
Quality 0-100 Frame 1, vision.quality

This replaces the earlier design of three separate always-visible sliders.

Bottom-right (over the video)

Four square buttons, same 40px height as the vision controls: Torso, Head, Sound, then Mode, left to right (exact order among the four is this session's pick, not specified). Each opens its own popup (see Popups); pressing the same button again closes it. Torso, Head and Mode are implemented; Sound is a bare toggle button with no panel behind it yet.

If the Wi-Fi link to the Pi drops while on the Main screen, a message is shown centered on screen, overlaid on top of the (frozen, dimmed) video and data — the app stays on the Main screen rather than navigating back to the Connection screen, and the overlay is dismissed once the link comes back. Implemented (controller/widgets/link_lost_overlay.py) — exact dim amount and wording ("Link lost") are this session's pick, not specified beyond "a message... overlaid... dimmed". Since the Connection screen doesn't wait for a handshake before navigating (see Connection screen), this overlay is also what a user sees if they land on Main before the link is actually up yet, not only if it drops later — the doc only describes the "drops later" case, but the same mechanism covers both since both are just "not connected".

Popups

Secondary parameter groups open as a popup over the Main screen rather than as separate navigation screens. Each popup's trigger is one of the four buttons in the bottom-right of the Main screen; pressing the same button again closes it (toggle — no separate close/cancel control). Every popup shows at the same fixed spot and the same size (300×400, anchored above the button row) regardless of which button triggered it — not sized or positioned around its own trigger button, so switching between popups doesn't move where you're looking.

Popup Trigger Status
Torso Torso button, bottom-right of the Main screen Built — see below. Moved here from a Menu page (see Menu pages)
Sound Sound button, bottom-right of the Main screen Content scoped (see below), not built yet. Implemented today as a bare toggle button, no panel
Head Head button, bottom-right of the Main screen Built — see below. Renamed from the earlier "Eye mode" idea, different content
Mode Mode button, bottom-right of the Main screen Content designed — see below. Implemented

Only one popup at a time is implied by there being one "row it appears above" and a shared toggle behavior, but what happens if you press one trigger while another popup is already open isn't decided as a product rule. Implemented as the first option (opening one popup closes any other) — this session's pick to resolve the ambiguity one way rather than leave the code branch unhandled, not a settled product decision.

Each popup will eventually get its own explanation page; not done yet, deferred along with the rest of this pattern.

Sound popup

Not built yet — no panel exists behind the existing toggle button. Scoped content, per Data & Controls:

  • Volume
  • Microphone level (meter, relevant while push-to-talk is active)
  • A button to trigger a sound from the local bank
  • An "AI sound mode" entry, for the robot's autonomous voice — placeholder only, some setup is still needed on the robot side before this does anything

Exact layout not designed yet, unlike Mode.

Torso popup

Built (controller/widgets/torso_popup.py). Ports manette-tester/tester.py's "Buste (Nano Every)" panel into the real app, same controls:

  • Fan speed (0-255 slider)
  • Ring brightness (0-255 slider)
  • 7 NeoPixel LED color swatches (LED 1 → 7) — tapping one opens a color picker for that LED only
  • "Same color for all" — one color picker, applied to all 7 at once
  • Button illumination brightness (0-255 slider)

torso.led is all-or-nothing on the wire (see CONTROLLER-PROTOCOL.md) — the popup keeps its own copy of all 7 colors locally and resends the whole list on every change, rather than addressing a single index. Every control emits through one torso_changed signal; screens/main_screen.py is the one that calls PiLink.update_torso(), same pattern as the vision sliders.

Head popup

Renamed from the earlier "Eye mode" idea, and built (controller/widgets/head_popup.py) with different content than first scoped:

  • Two sliders, one per antenna (head.antenna_right/head.antenna_left, 0-1023) — until now these had no control at all, not even the physical stick (see Joystick mapping, which only covers the neck axes and triggers)
  • Vibrator on/off (head.vibrator) — a plain toggle sending exactly 0 or 1, not the field's full 0-255 range; this session's requested behavior
  • "Recenter head" — resets all 5 head servos (both antennas + all 3 neck axes) to their documented centers in one press, same values manette-tester/tester.py's "Recentrer la tete" button already uses (HEAD_CENTERS, matching common/models.py's HeadCommand defaults)
  • Distance reading (head.distance_cm) — display only, no control. Always 0 today (sensor not read yet, see Data & Controls); shows "--" until at least one head telemetry section has arrived, the same convention the persistent banner already uses for the robot's battery reading

Eye mode and the earlier "antenna mode"/"head mode" abstractions from the original scoping aren't addressed by this popup — not decided, not built, left as they were.

Reads telemetry rather than only sending commands, the only popup that does — its own deferred-connect TOPIC_TELEMETRY socket, same discipline as the persistent chrome: connects only once set_link() is called, closed only at app shutdown.

Mode popup

Mode popup draft: header bar labeled "Mode", three stacked options — Sitting, Standing, Moving

300×400 (taller than wide), a separate draw.io diagram (mode_popup.svg, same editing convention as main_screen.svg) rather than folding it into the Main screen's canvas — keeps each popup a self-contained, independently editable file instead of one increasingly dense diagram. Three options for now: Sitting, Standing, Moving — the rest of what belongs in this popup isn't decided yet, though pre-recorded action selection (choreography_id) is planned to join these options once that field is exposed on Frame 1 (see Data & Controls) — not built yet, no second option type in the popup's code today.

Implemented (controller/widgets/mode_popup.py): selecting an option only highlights it, nothing is sent to the Pi yet — Data & Controls lists "Operating mode selection" as "Not defined yet" on the wire protocol, so there's no Frame 1 field to put it in. Wiring it up once that field exists is one PiLink call, not a redesign.

Balance tuning page

The first Menu page designed in detail, and built (controller/screens/balance_screen.py). Reached from the Menu screen, robot stationary (held or on a stand) rather than being actively driven, which is why this page drops the full-bleed video Main relies on: there's nothing lost by not showing it here, since the person tuning is watching the robot itself, not its camera.

Layout

The persistent chrome (banner, Menu button, Emergency-stop buttons) covers armed/estop visibility and the emergency stop, unchanged from every other screen. Below it, two columns:

+------------------------------------------------------------------+
|            (persistent chrome: banner + Menu + Estop)            |
+------------------------------------------------------------------+
|  Torso pitch (torso_inclination.x)      | Setpoint & mode         |
|  [ rolling chart ]                      | tilt_setpoint_deg   ... |
|                                          | height               ... |
|  Wheel speed (left / right)             | balance [ ]             |
|  [ rolling chart, two traces ]          |-------------------------|
|                                          | Tilt PID                |
|  Forward speed / rotation speed         | kp / ki / kd            |
|  [ rolling chart ]                      |-------------------------|
|                                          | Speed damping           |
|                                          | kvel_p / kv_outer       |
|                                          |-------------------------|
|                                          | Yaw damping             |
|                                          | kyaw_p / kyaw_i         |
|                                          |-------------------------|
|                                          | Trim                    |
|                                          | motor_balance/yaw_trim  |
+------------------------------------------------------------------+

Left column: three rolling charts (see below). Right column: one row per gain, showing its label and current value. Both columns sit inside the fixed 1200×800 canvas, laid out with a Qt layout manager rather than the absolute pixel positioning main_screen.py uses, since this page has no pixel-precise mockup to match the way Main does.

Gain entry

No slider, and no increment/decrement stepper — this is deliberate tuning done with the robot stationary, not an adjustment made on the move, so a slower, more precise action is preferred over a quick tactile one. Each gain row shows its label and current value as read-only text; tapping the value opens a touch numeric keypad (widgets/numeric_keypad.py, "float" mode: digits, decimal point, and a +/- sign, since some fields such as yaw_trim and tilt_setpoint_deg can be negative — there is no physical keyboard on the Legion Go to type numbers otherwise). Confirming applies the typed value immediately through PiLink.update_legs(...), the same immediate-apply convention already used by the vision sliders on Main. No range is enforced by the keypad beyond rejecting non-numeric input — none of these gains have a documented range (see Data & Controls), only default values.

Same widget, "text" mode, is reused for the Connection screen's IP field (see Connection screen) — one keypad file, not two nearly-identical ones.

Charts

Three rolling line charts: torso pitch (torso_inclination.x), wheel speed (wheel_speed.left/.right, two traces), and forward/rotation speed. torso_inclination.y/.z get no chart of their own, per the no-data convention — they're permanently 0, not a real reading.

The general page redraws at 30fps, matching the rate already used elsewhere in the app (video, joystick polling). The charts themselves sample telemetry at 10Hz, matching legs' actual source rate from bus-pico (see zig-pi/CONTROLLER-PROTOCOL.md) — sampling faster wouldn't produce more real data. Frame 2 as a whole can arrive faster than 10Hz (pushed by head, which updates at 10-30Hz) without legs having changed; each arriving sample is still plotted as-is, with no deduplication against legs.sequence — at 30fps redraw, a legs value occasionally repeated for two or three consecutive samples isn't visible as a discrepancy, so the extra filtering isn't worth adding.

Persistence

Implemented (common/balance_presets.py): every gain value applied through the keypad is written immediately to controller/config/balance_gains.json, so nothing is lost on restart. That file is committed to git, not ignored — the Controller runs from the same repository checkout on more than one machine (a development PC and the Legion Go), and git is how they stay in sync. The exact path is this session's pick, not a settled convention for future settings files.

A "Back to Menu" control returns to the Menu screen, following the same star topology as every other Menu page.