LED matrix & MCU·
The STM32U585 owns the pins and the LED matrix. Linux talks to it through `arduino-router`, a msgpack-RPC bridge that Arduino ships on the board.
The bridge·
arduino-router.service (root) holds /dev/ttyHS1 at 115200 and exposes a unix socket at /var/run/arduino-router.sock, writable by any user. Never open the tty directly; it reports "Device or resource busy".
Wire format is plain msgpack-RPC:
| kind | shape |
|---|---|
| request | [0, msgid, method, [params]] |
| response | [1, msgid, error \| nil, result] |
| notify | [2, method, [params]] |
Errors are [code, message]. The router routes by method name between every connected client, Linux or MCU.
Router meta-methods: $/register name (expose a Linux-side method to the MCU), $/unregister, $/cancelRequest msgid. Error 0x05 means the route already exists.
Router-internal methods: mon/connected mon/read mon/write mon/reset, hci/open hci/close hci/send hci/recv hci/avail, tcp/connect tcp/connectSSL tcp/listen tcp/accept tcp/read tcp/write tcp/close tcp/closeListener, $/serial/open $/serial/close.
Verified live with stdlib Python and no libraries: mon/connected → true, hci/avail → error [2, "No HCI device open"].
MCU side·
The MCU runs Zephyr (core arduino:zephyr:unoq 0.52.0, variant arduino_uno_q_stm32u585xx): a loader plus a dynamically linked sketch. Sketches use Arduino_RouterBridge on Serial1: Bridge.begin(), Bridge.provide(name, fn), Bridge.call(name, args...), Bridge.notify(...). The matrix library is Arduino_LED_Matrix (loadFrame(uint32_t[4]), renderBitmap, text scroll).
Flashing from Linux: arduino-flash <sketch>.elf-zsk.bin, which is openocd with SWD bit-banged over gpiod, writing at 0x80F0000. arduino-reset resets the MCU. The toolchain (arm-zephyr-eabi 0.16.8, zephyr-sketch-tool) is on the board, so arduino-cli compile -b arduino:zephyr:unoq runs there.
The q sketch·
q/mcu.py is a msgpack-RPC client to the router socket (Q_ROUTER_SOCKET to override, Q_SIM=1 for an in-process fake). The sketch in firmware/q/q.ino (q-fw-0.2, flashed with scripts/board/flash-mcu.sh, factory sketch backed up under ~/.q/backup) provides q/ping → "pong", q/version → "q-fw-0.1", q/uptime → millis, q/frame(w0..w3), q/text(s) (scrolls once from loop() so the call returns at once), q/clear, q/adc(n) for A0 to A5, and since q-fw-0.2 the header GPIO: q/pin_mode(n, m) (0 input, 1 output, 2 input pull-up), q/pin_write(n, v), q/pin_read(n) for D2 to D13 (D0/D1 are the header UART and answer -1; q/pin_pwm is reserved and answers -1 because analogWrite() stops the dynamically linked sketch from starting, bisected on 2026-09-09). q/led.py drives both the sysfs RGB user LED on Linux and the matrix through those methods, and records whether the last frame was applied. Static glyphs (q/glyphs.py) and the animated face (q/ext/face.py) both go through q.led.frame(), so the dashboard's matrix preview stays the truth.
At rest the matrix shows the status glyph: three columns, Wi-Fi bars by RSSI, tunnel up or down, doctor pass or fail. q-dash draws it once the MCU is linked after every start (Q_LED_BOOT, default status) and redraws it when those three facts change (Q_LED_STATUS_EVERY_S, default 30 s), but only while the glyph is still what is on the glass: any text, frame, other glyph, clear or the face animation takes the matrix over until the next start.
Verified on the board on 2026-09-09: q mcu q/version → q-fw-0.2; q/pin_write 2 1 then q/pin_read 2 → 1, one write round trip 6 to 7 ms; q mcu q/adc without a parameter → router error 253 "Missing call parameters" (the sketch wants n).
Header pins·
D2 to D13 are plain GPIO from Linux: q/pin_mode, q/pin_write, q/pin_read on the sketch, wrapped by q.mcu, the CLI (q pin write D2 1, q pin read laser, q pin list), the API (PUT /api/pins/{pin}) and the agent tool q_pin. D0 and D1 are the header UART that carries the Linux to MCU link, refused everywhere, greyed in the dashboard.
Every pin parameter is resolved by one function, q.mcu.resolve_pin, and accepts 2, "2", "D2" or an alias. Aliases live in ~/.q/pins.json on the board, read on every call (edit the file, no restart), missing file means no aliases:
With that file PUT /api/pins/laser {"level": 1}, q pin write laser 1 and q_pin("laser", level=1) all drive D2, the answer names both (laser (D2) high), and the grid shows the alias under D2. An unknown name answers 422 unknown pin 'foo' (D2..D13 or an alias in ~/.q/pins.json).
Worked example: a laser on D2·
A pin sources a few milliamps at 3.3 V; a laser module wants 5 V and 20 mA or more, so it never sits on a pin. Low-side switch:
5V ─────────────── laser +
laser − ─────────── 2N2222 collector
2N2222 emitter ──── GND
D2 ── 1 kΩ ──────── 2N2222 base
D2 high turns the transistor on and powers the laser; D2 low cuts it. {"laser": 2} in ~/.q/pins.json gives the pin its name. The software proves the pin level (q/pin_read reads back 1), not the beam.
Not yet verified·
- The pixels themselves.
applied: truemeans the MCU acknowledged the frame; nobody has compared the glass to the preview pixel by pixel. - MCU die temperature and reset reason: not reachable from a sketch with the stock loader. The device tree enables
dietemp, but the loader's export tables (variants/arduino_uno_q_stm32u585xx/syms-dynamic.ld,syms-static.ld) contain nohwinfo_*,sensor_*oradc_*symbols andCONFIG_SENSORis off, so a dynamically linked sketch cannot call them. Reading them means rebuilding the Zephyr loader, which is out of scope for q. - Per-pixel RGB:
Arduino_LED_Matrix.hoffers it; the sketch drives 1-bit frames only. - Anything beyond the pin.
PUT /api/pins/laser {"level": 1}makes D2 read 1 on the STM32 (verified 2026-09-10 through the CLI, the API, the dashboard grid and the agent); whether the transistor and the load follow has not been confirmed by eye yet.