MPU workflow¶
The Linux side: direct hardware access, the unoq package for talking to the
MCU, and the CPU-bars demo that uses both halves of the board at once.
Direct hardware¶
The MPU's own GPIO / I²C / SPI — separate from the Arduino headers, which are wired to the MCU.
| Interface | Device | Notes |
|---|---|---|
| GPIO | /dev/gpiochip0..2 |
chip1 has 127 lines; also carries SWD + BOOT0 |
| I²C | /dev/i2c-0..2 |
needs the i2c group |
| SPI | /dev/spidev0.0 |
needs the spi group + udev rule. It is wired to the MCU, not a free bus: see hardware.md |
| Serial | /dev/ttyHS1 |
the MCU link |
Group membership (i2c, spi, gpiod) and the SPI udev rule are set up by
provision/10-optimize-board.sh. A new
group takes effect at your next login.
The unoq package¶
Installed editable from ~/two-computers-one-board/python into ~/two-computers-one-board/.venv.
from unoq import MCU, fota, link_up, link_state
link_up() # BOOT0 low + UART enable (idempotent)
link_state() # {'boot0': 'OUTPUT', 'link_enable': 'OUTPUT'}
with MCU() as mcu:
mcu.status() # {'uptime_ms': 12216, 'flip': 0, 'sweeps': 1043712}
mcu.die_temp() # 31.25 - the STM32's own temperature, degrees C
mcu.bars([12, 3, 0, 47]) # LED matrix: one bar per value, 0..100
mcu.matrix_px(7, 0) # light one LED - finds the panel's orientation
mcu.matrix_flip() # rotate 180 degrees, persisted in NVS
mcu.matrix_off()
mcu.cmd('kernel threads') # any shell command, parsed into lines
The class wraps the firmware's own app command group and nothing else. The
shell's built-in groups — gpio, i2c, device, kernel, devmem — are one
mcu.cmd() away, or you can type them straight into tio; wrapping each in a
Python method bought nothing but more surface to keep in step with Zephyr.
mcu.die_temp() is the one temperature on the board Linux cannot read for
itself: every zone in /sys/class/thermal belongs to the QRB2210. It is the
STM32U585's internal sensor, converted by Zephyr's driver with the two
calibration values ST programs into each chip at the factory — so it is
calibrated, but it is still a junction temperature from an on-chip sensor, good
to a few degrees and always above the air around the board. It raises MCUError
if the firmware could not read the sensor (the shell prints temp_err=<errno>
in place of a reading) or is too old to have app temp.
MCU is a context manager on purpose — the UART is a single shared
resource and a stale handle blocks tio, mcucon, and SMP alike.
mcu.cmd() reads until the shell prompt reappears rather than sleeping a fixed
time, so slow commands (device list) and fast ones each cost what they should.
It raises MCUError on command not found and on any line the firmware
prefixes with Error:, and ShellTimeout if no prompt comes back.
Temperatures¶
from unoq import read_temps, hottest
for name, celsius in read_temps():
print(f"{name:20} {celsius:5.1f}") # cpuss0-thermal 35.8
hottest(read_temps(), "cpu") # ('cpuss1-thermal', 36.1)
read_temps() reads the kernel's thermal zones from /sys/class/thermal and
returns (type, degrees Celsius) pairs in zone order. It is a list of pairs
rather than a dict because nothing makes a zone's type unique, and how many
zones there are is the kernel's business: this board showed six on one kernel
and ten on the next. A zone whose sensor will not answer is left out rather
than raised, and a machine with no thermal zones reads as an empty list.
hottest() picks the warmest pair, optionally narrowed by a name prefix, and
returns None if nothing matches. Nothing here writes to sysfs.
These are the Linux side's sensors. None of them says anything about the microcontroller.
Firmware update¶
from unoq import fota
fota.images() # slot table
fota.upload(path); fota.test(); fota.reset(); fota.confirm()
See mcu.md.
What runs at boot¶
Eight systemd units, of which a board set up with a plain ./bootstrap.sh has
the first two. The others arrive with the flag named.
| Unit | What it does | Installed by | Runs as | Kind |
|---|---|---|---|---|
unoq-link |
Drives BOOT0 low and the serial-link enable high, so the MCU runs its firmware and /dev/ttyHS1 carries data |
always (provision/45-mcu-link.sh) |
root | one-shot, stays active |
unoq-leds |
Drives LED 1 (connectivity) and LED 2 (does anything need a human) | always (provision/50-status-leds.sh) |
root | long-running, always restarted |
unoq-cpu-bars |
Draws CPU load on the LED matrix. Holds /dev/ttyHS1 |
default; not with --no-cpu-bars (provision/65-cpu-bars.sh) |
you | long-running |
unoq-learn |
Serves the documentation and installers over HTTP on port 8080 | default; not with --no-learning (provision/75-learning-web.sh) |
you | long-running |
unoq-usb-gadget |
Builds the USB gadget definition and prepares the br-usb bridge. Never binds |
default; not with --no-usb-gadget (provision/95-usb-gadget.sh) |
root | one-shot, stays active |
unoq-usb-bind |
Asks the bind guard, then binds the gadget and brings the USB network up. Not enabled at boot: udev starts it when a USB device controller or the gadget's network interface appears | default; not with --no-usb-gadget |
root | one-shot, re-run on every plug-in |
unoq-usb-confirm |
Waits 120 seconds, then clears the bind guard's counter: this boot survived | default; not with --no-usb-gadget |
root | one-shot |
unoq-uplink-fallback |
If wifi has been turned off and the USB link has given no internet by the deadline, turns wifi back on | default; not with --no-usb-gadget |
root | one-shot |
"You" is the account that owns the checkout: the unit files are written with
arduino and rewritten to the real user and path when they are installed.
The six that run as root do not run the scripts in the checkout. They run
root-owned copies in /usr/local/lib/unoq/, which the step that installs each
unit makes first. After editing one of those scripts, run the step again for
the board to use it: see What root runs.
The two that run as you run from the Python environment in the checkout.
The four USB units are explained in usb.md and The bind guard.
Swap in use is not a problem here¶
free -h on this board can show about 1 GiB of swap in use. On a
desktop that means the machine is short of memory. Here it does not, because
this board's swap is not what swap usually means.
There is no swap file and no swap partition on the eMMC. The swap device is
zram0: a compressed block device that lives in RAM. "Swapping" here means
compressing cold pages in place, not writing them to flash.
What it saves¶
One measurement, 3h41m after boot, with a full development environment running:
| Pages parked | 1010 MiB |
| Compressed to | 316 MiB (3.20×, lzo-rle) |
| Real RAM occupied | 331 MiB |
| Net RAM saved | 678 MiB |
The first three numbers on that line are, in bytes: the size of the pages
parked (orig_data_size), what they compressed to (compr_data_size), and the
RAM the device really occupies (mem_used_total). The kernel documents the
rest in Documentation/admin-guide/blockdev/zram.rst.
The ratio is the first divided by the second, 1010 / 316. The saving is the first less the third, which is a little more than the compressed data. Straight after boot all three are near zero: nothing has been parked yet.
Parking or thrashing¶
Swap being used tells you nothing. What matters is whether pages that were swapped out keep being read back.
The same measurement gave pswpout 277888 and pswpin 15991 since boot. Pages
went in and were read back roughly 1 time in 17. That is cold memory being
parked and left alone, which is the behavior you want. Thrashing is the other
case: pages being read back about as fast as they are written out.
Why this is the right design on eMMC¶
zram is why a 3.6 GiB board comfortably runs a full IDE, and it costs no flash wear at all. A conventional swap file on the eMMC would quietly eat write endurance.
The desktop instinct — swap is in use, so the board is under-provisioned, so start disabling things — reaches the opposite of the right conclusion.
Where the memory goes¶
The deployed system is not the load. From the same session:
| RSS | |
|---|---|
unoq-cpu-bars |
26.6 MiB |
unoq-learn |
20.7 MiB |
unoq-leds |
3.8 MiB |
unoq-usb-gadget |
0.7 MiB |
| all four services | ~52 MiB |
| vscode-server + Claude Code extension + ruff server | 1373 MiB |
Everything this project installs costs about 52 MiB. The rest is the development environment running on the board, which is a legitimate thing to do here. It is why the numbers look alarming during development, and why they will not look like that for someone who only runs the apps.
CPU bars on the LED matrix¶
Host CPU load, one bar per core, on the board's 8×13 panel. It is the smallest
thing that uses both halves of the board for what each is good at: Linux reads
/proc/stat, the STM32 holds a 100 kHz refresh loop steady while it does.
sudo systemctl stop unoq-cpu-bars # if the service is installed: it holds the port
hpy -m unoq.cpubars # until Ctrl-C
hpy -m unoq.cpubars --interval 0.25 --count 20
hpy is the project's Python (an alias from env.sh). The same program is
installed as ~/two-computers-one-board/.venv/bin/unoq-cpu-bars, which is what
the service runs; that directory is not on PATH, so type the full path or use
hpy -m unoq.cpubars.
from unoq import MCU, CpuSampler
sampler = CpuSampler() # takes the first reading
with MCU() as mcu:
mcu.bars(sampler.sample()) # [12, 3, 0, 47] -> four bars
/proc/stat holds counters since boot, so a single reading is the average since
power-on; keep one CpuSampler alive and take differences.
At every boot¶
sudo bash ~/two-computers-one-board/provision/65-cpu-bars.sh # install + enable
journalctl -u unoq-cpu-bars -f
It holds
/dev/ttyHS1open, and locks it. While the service runs,mcucon,tio,unoq.MCUand FOTA are all refused; the Python ones raisePortBusyand name the process holding the port. Stop it before working on the MCU, and start it again afterwards:The lock is two locks, taken by
open_port()inpython/unoq/mcu.py. pyserial'sexclusive=Trueis an advisoryflock: it stops only another program that asks for the same lock, andtio,screenandcatdo not. TheTIOCEXCLioctl is what makes the kernel refuse every lateropen(). See troubleshooting for how the second one can outlive the program that set it.Flashing over SWD (
flash.sh,zflash) is unaffected — that is OpenOCD on the GPIO lines, not the UART.
The unit sets KillSignal=SIGINT, which is load-bearing rather than
stylistic. systemd's default SIGTERM kills Python outright, so the finally:
that blanks the panel never runs and the last frame stays lit indefinitely —
measured: SIGTERM exits 143 with the refresh still sweeping, SIGINT exits 0 with
the panel dark. Restart=on-failure covers the case where the MPU reaches
multi-user.target before the MCU's shell is answering.
It also declares Requires=unoq-link.service, because /dev/ttyHS1 reads
nothing until that unit has driven the UART-enable GPIO. Ordering alone would
let this start against a silent port and restart forever.
Which half does what¶
| Runs on | File | |
|---|---|---|
Read /proc/stat, turn counters into percentages |
MPU | python/unoq/cpu.py |
Send app bars 12 3 0 47 over /dev/ttyHS1 |
MPU | python/unoq/mcu.py |
| Percentages → 104 grayscale pixels | MCU | mcu/app/src/bars.c |
| Charlieplex those pixels onto PF0–PF10 | MCU | mcu/app/src/matrix.c |
The wire format is whole percentages, so app bars 100 50 0 0 typed into tio
does exactly what the daemon does. Nothing on the MCU knows what a CPU core is,
and nothing on the MPU knows how a bar is drawn.
Reading the display¶
Bars are 2 columns wide with a 1-column gap, centered, growing from the bottom.
Eight rows would quantize load into 12.5% steps, so the top pixel of each bar is dimmed in proportion to the remainder — the 3-bit grayscale is used as a vernier, giving 56 distinguishable heights instead of 8.
The scale is linear, which has one consequence worth knowing before you decide the display is broken: a core needs 13% before it lights one row at full brightness, and 2% before it lights anything at all. An idle UNO Q therefore shows a dark panel with at most a faint dot or two along the bottom edge, and looks exactly like a service that failed to start. Put some load on before judging it:
The dark idle state is deliberate — a baseline glow would be indistinguishable from a low but real load.
Orientation¶
The panel's rows run in whichever direction your board is mounted, and the firmware cannot know which. If the bars hang from the top instead of standing on the bottom, flip them once:
unoq:~$ app matrix px 7 0 7 # lights the bottom-left LED, as you should see it
unoq:~$ app matrix flip # wrong corner? rotate 180 degrees
ok flip=1
flip is stored in NVS, so it survives reboots and firmware updates. app
status reports it. From Python: mcu.matrix_px(7, 0) and mcu.matrix_flip().
Is it actually running?¶
A charlieplexed panel is invisible to software — nothing the MCU can read back tells you what is lit. The one observable is the refresh sweep counter:
hpy -c "
from unoq import MCU; import time
with MCU() as m:
t0 = time.monotonic(); a = m.status()['sweeps']
time.sleep(1)
t1 = time.monotonic(); b = m.status()['sweeps']
print(round((b - a) / (t1 - t0)), 'sweeps/s')"
Expect ~962/s while the panel is on (104 LEDs × 10 µs per slot); measured 958. A count stuck at zero means the refresh timer never started; a count that climbs means the ISR is running whether or not you can see the LEDs from where you are.
Divide by the measured interval, not by the sleep. A
status()round trip costs ~0.30 s —cmd()blocks inread()until its timeout even after the prompt has arrived — so the obvioussleep(1); b - aversion reports ~1250 and looks like a panel refreshing 30% faster than the hardware allows.
The panel starts lazily on the first app bars, and app matrix off stops the
timer and releases the pins.
Shell commands¶
app status uptime_ms, flip, sweeps
app temp temp_mc=<n>: the MCU's die temperature, millidegrees C
app bars <pct> [pct ...] 1..7 values, 0..100 each
app matrix px <row> <col> <lvl> light one LED; row 0..7, col 0..12, lvl 0..7
app matrix flip rotate 180 degrees, persisted
app matrix off blank the panel, stop the refresh
Why seven bars¶
Bars are at least one column wide and always separated by one blank column, so
n bars need 2n−1 columns. Thirteen columns therefore cap it at seven. The
UNO Q's MPU has four cores, so this is not a limit you will meet; a bigger host
gets cpu0..cpu6 and a warning on stderr, never a silent truncation.
How the panel works¶
104 LEDs on eleven pins (PF0–PF10), charlieplexed: each LED is one ordered pair of pins, lit by driving one high, one low, and leaving the other nine floating as inputs. Eleven pins give 11 × 10 = 110 ordered pairs; the panel uses the first 104.
Only one LED is ever on. A TIM17 counter callback walks all 104 every 1.04 ms, and brightness comes from lighting a given LED in L of the seven sweeps that make up a grayscale cycle — so the whole panel redraws at ~137 Hz.
That is why matrix.c writes MODER and BSRR
directly instead of calling gpio_pin_configure_dt(): pins must change
direction, not just level, 100,000 times a second.
Upstream Zephyr's
arduino_uno_qboard does not describe the matrix at all. The pin pairing and the 10 µs slot were taken from Arduino's own core (ArduinoCore-zephyr,loader/matrix.inc) — the only description of this wiring that exists. Nothing else from that core is used here, and the devicetree nodes it needs are declared inmcu/app/boards/arduino_uno_q.overlay.
Gotchas¶
- PF0–PF10 belong to the panel. Anything else in the build that claims them will fight the refresh ISR. PF11–PF15 are untouched.
- The refresh ISR runs at 100 kHz whenever the panel is on. It is cheap, but it
is not free —
app matrix offif you are measuring something timing-sensitive. unoq-cpu-barsis not onPATH: it is installed into the project's venv, at~/two-computers-one-board/.venv/bin/unoq-cpu-bars. Use that path, orhpy -m unoq.cpubars.- Close the shell handle before using SMP.
MCUandfotaboth want the same UART, and reopening it immediately after a close drops the first SMP frame — which looks exactly like a firmware fault but is a host-side race. Leave thewith MCU()block first, and give it a moment. cbor2is capped<6.0.0bysmp, andpydanticpinspydantic-coreexactly. Both show as "outdated" and must not be upgraded — see the maintenance notes in Working on this project.- Reading
/dev/ttyHS1returns zero bytes if GPIO line 70 is not driven high.MCU()callslink_up()for you; rawpyserialdoes not.