Drive both Framework Laptop 16 LED Matrix modules as a live system monitor on Fedora
Published 19 July 2026
- date
- updated
- env
- linux Fedora 44 (Framework Laptop 16, Ryzen AI 9 HX 370 / Radeon 890M) macos N/A
- tags
Symptom
You have two LED Matrix input modules installed in the Framework Laptop 16 — one to the left of the keyboard, one to the right — and you want them to display live system metrics: CPU, memory, temperature and fan speed, arranged however you like, as bars, as figures or as pictograms, without editing Python every time you change your mind.
Out of the box, several things get in the way:
- The two modules are electrically identical and report the same USB serial number, so you cannot tell left from right by serial, and the
/dev/ttyACM*ordering is not stable across reboots. ledmatrixctlrejects a/dev/...symlink withFailed to find requested device.ledmatrixctlitself fails to start on a clean Fedora withModuleNotFoundError: No module named 'tkinter', even for--list.- Fans read
0 RPMat idle and are not obvious insensors. - A naive systemd service fails on every restart with
ModuleNotFoundError: No module named 'serial', even though the script runs fine from your shell. - On battery, a permanently lit display is a power decision you may want to revisit — and hard-coding it in the script means restarting the service to change your mind.
Root cause
Each friction above has a concrete explanation:
- Identical serial numbers. Both matrices expose the same serial (
FRAKxxxxxxxxxxxxxx). The only stable discriminator is the physical USB port path (ID_PATH), not the serial and not thettyACMindex. - udev rule ordering. The
ID_PATHproperty is computed by a system rule numbered60-*. A custom rule that matches onENV{ID_PATH}must therefore run after it — i.e. its filename must sort after60-(use99-). A51-rule silently matches nothing. - Symlinks vs
ledmatrixctl. The tool enumerates serial ports through pyserial, which returns only the canonical path (/dev/ttyACM0), then matches--serial-devby exact string. A symlink never matches; it must be resolved withreadlink -f. - The tkinter failure.
framework16-inputmodule0.1.1 imports its GUI module fromcli.pyunconditionally, so evenledmatrixctl --listpulls in PySimpleGUI, which importstkinter. Fedora does not shiptkinterin the basepython3package. - Fan/temperature reading. Reading the Framework EC’s fans and temperatures is mainline since Linux 6.11 through the
cros_ec_hwmondriver — no out-of-tree kernel module is needed. Fans sitting at0 RPMwhen the machine is cool is expected behaviour (silent fan curve), not a failure. No module named 'serial'under systemd.miseactivates the Python runtime through the shell. A systemd user service does not inherit that activation, so itspython3resolves to the system interpreter, which has nopyserial. The fix is to pin a dedicated virtualenv for the service.
Diagnosis
Check what is already installed:
command -v pipx ledmatrixctl sensorsrpm -q python3-tkinter lm_sensorsConfirm both modules are present:
ledmatrixctl --listFind the stable physical path of each module (note which ttyACM is which side — light them one at a time if unsure):
udevadm info -q property -n /dev/ttyACM0 | grep ID_PATH=udevadm info -q property -n /dev/ttyACM1 | grep ID_PATH=Confirm the EC fans and temperatures are exposed by the mainline driver:
sensors | grep -iA6 cros_ecA cros_ec-isa-0000 block with fan1 / fan2 and temp* confirms cros_ec_hwmon is active and readable without root.
Solution
Prerequisites / warnings
- Kernel 6.11 or newer for
cros_ec_hwmon(Fedora 44 is well beyond this). - Python 3.11 or newer for
tomllib, used to read the configuration file. Fedora 44 ships 3.14. - Do not rely on
miseto providepyserialto the service — use a dedicated venv (step 6). - The udev access rule grants the logged-in user access to the modules; without it
ledmatrixctlneeds root.
Steps
1. Install the tooling. A clean Fedora Workstation ships none of it.
sudo dnf install -y pipx python3-tkinter lm_sensorspipx install framework16-inputmodulepipx ensurepathpipx ensurepath appends ~/.local/bin to your PATH via userpath, but the current shell does not pick it up. Open a new terminal, or:
exec $SHELL -lledmatrixctl --listpython3-tkinter is not optional despite having nothing to do with the task — see Troubleshooting. lm_sensors is only a diagnostic convenience: the monitor reads /sys/class/hwmon/ directly. framework_tool (from framework-system) is likewise optional, used once to calibrate the fan scale.
2. Grant non-root access to the modules (udev uaccess).
sudo tee /etc/udev/rules.d/50-framework-inputmodule.rules > /dev/null <<'EOF'SUBSYSTEMS=="usb", ATTRS{idVendor}=="32ac", ATTRS{idProduct}=="0020", MODE="0660", TAG+="uaccess"SUBSYSTEMS=="usb", ATTRS{idVendor}=="32ac", ATTRS{idProduct}=="0021", MODE="0660", TAG+="uaccess"SUBSYSTEMS=="usb", ATTRS{idVendor}=="32ac", ATTRS{idProduct}=="0022", MODE="0660", TAG+="uaccess"EOFsudo udevadm control --reload && sudo udevadm trigger3. Create stable left/right symlinks (this is the part that fails with a 51- rule).
Get each module’s ID_PATH (see Diagnosis), then create the rule numbered 99- so it runs after ID_PATH is set. Replace the two paths with your own values:
sudo tee /etc/udev/rules.d/99-framework-ledmatrix-sides.rules > /dev/null <<'EOF'SUBSYSTEM=="tty", ENV{ID_PATH}=="pci-0000:c6:00.0-usb-0:3.3:1.0", SYMLINK+="ledmatrix-right"SUBSYSTEM=="tty", ENV{ID_PATH}=="pci-0000:c6:00.0-usb-0:4.2:1.0", SYMLINK+="ledmatrix-left"EOFsudo udevadm control --reload && sudo udevadm triggerVerify:
ls -l /dev/ledmatrix-*You should see ledmatrix-left and ledmatrix-right pointing at the two ttyACM* devices.
4. Confirm fans and temperatures via the mainline cros_ec_hwmon driver.
sensors | grep -iA6 cros_ecIf the cros_ec block is missing, load the driver (it normally auto-loads):
sudo modprobe cros_ec_hwmonecho cros_ec_hwmon | sudo tee /etc/modules-load.d/cros_ec_hwmon.conf5. Install the monitor, its validator, and a configuration file.
The monitor is a single self-contained script — pyserial is its only third-party dependency. It speaks the raw matrix protocol (StageGreyCol + DrawGreyColBuffer), resolves the /dev/ledmatrix-* symlinks itself, reads CPU utilisation from /proc/stat, memory from /proc/meminfo, and temperature/fans from hwmon.
Source: https://github.com/eloudsa/mylinuxtips/tree/main/framework/ledmatrix
mkdir -p ~/.local/bin ~/.config/ledmatrix-sysmon ~/.config/systemd/usercurl -fsSL -o ~/.local/bin/ledmatrix-sysmon.py https://raw.githubusercontent.com/eloudsa/mylinuxtips/main/framework/ledmatrix/ledmatrix-sysmon.pycurl -fsSL -o ~/.local/bin/ledmatrix-config-check https://raw.githubusercontent.com/eloudsa/mylinuxtips/main/framework/ledmatrix/ledmatrix-config-checkcurl -fsSL -o ~/.config/ledmatrix-sysmon/config.toml https://raw.githubusercontent.com/eloudsa/mylinuxtips/main/framework/ledmatrix/config.toml.examplechmod +x ~/.local/bin/ledmatrix-sysmon.py ~/.local/bin/ledmatrix-config-checkThe configuration file is optional — with no file at all, every setting falls back to a working default. See Configuration below.
6. Create a dedicated virtualenv for the service (decoupled from mise):
/usr/bin/python3 -m venv ~/.local/share/ledmatrix-venv~/.local/share/ledmatrix-venv/bin/pip install pyserial7. Calibrate the fan scale. Force the fans to full duty, read the RPM, then return to automatic control:
sudo framework_tool --fansetduty 100sleep 5sudo framework_tool --thermalsudo framework_tool --autofanctrlOn the Ryzen AI 9 HX 370 model both fans top out around 5285 RPM, so fan_max_rpm = 5300.0 under [scale] is the right value.
Confirm the monitor sees the right sensors, and which hwmon device holds your CPU temperature:
~/.local/share/ledmatrix-venv/bin/python ~/.local/bin/ledmatrix-sysmon.py --list-sensorsYou want a cros_ec block exposing fan1_input / fan2_input, and a k10temp block — the default value of scale.preferred_temp_hwmon on AMD.
8. Validate the configuration, then test on the hardware.
ledmatrix-config-checkThis prints any problems found, the effective settings with overrides marked, the resolved layout with its physical positions, and whether the display would be lit right now. Then a dry run and a single frame:
~/.local/share/ledmatrix-venv/bin/python ~/.local/bin/ledmatrix-sysmon.py --preview --once~/.local/share/ledmatrix-venv/bin/python ~/.local/bin/ledmatrix-sysmon.py --once --no-clearRun the last one again without --no-clear to blank both matrices.
9. Install the systemd user service.
tee ~/.config/systemd/user/ledmatrix-sysmon.service > /dev/null <<'EOF'[Unit]Description=LED Matrix system monitor (Framework 16)After=graphical-session.targetConditionPathExists=/dev/ledmatrix-left
[Service]ExecStart=%h/.local/share/ledmatrix-venv/bin/python %h/.local/bin/ledmatrix-sysmon.pyRestart=on-failureRestartSec=3TimeoutStopSec=5
[Install]WantedBy=default.targetEOFsystemctl --user daemon-reloadsystemctl --user enable --now ledmatrix-sysmon.serviceConditionPathExists matters. The LED Matrix modules — like the other Framework Laptop 16 expansion modules — can be removed at any time, and an enabled service whose device is absent would fail on every login and retry in a loop. With the condition, systemd skips the start cleanly: Active: inactive (dead) with a ConditionPathExists=... was not met line in the journal, no failure, no restart loop.
TimeoutStopSec is a safety net rather than a necessity — the monitor bounds its serial writes, so it always notices SIGTERM — but 5 s is a saner ceiling than the 45 s the user manager applies by default, should anything ever block unexpectedly. See Troubleshooting.
No command-line options belong in the unit. Everything lives in the configuration file, which the monitor re-reads while it runs — including display.enabled, which blanks the matrices without stopping the service.
10. Declare your autostart policy.
Step 9 leaves the unit enabled as a current state. Making that a declared policy means it survives a future systemctl --user preset / preset-all. The /etc/systemd/user-preset/ directory does not exist by default on Fedora (the distro ships its presets in /usr/lib/systemd/user-preset/ and leaves /etc for local overrides), so create it first:
sudo mkdir -p /etc/systemd/user-presetsudo tee /etc/systemd/user-preset/90-ledmatrix-sysmon.preset > /dev/null <<'EOF'enable ledmatrix-sysmon.serviceEOFsystemctl --user is-enabled ledmatrix-sysmon.serviceWrite disable instead if you would rather start the matrices on demand. The 90- prefix sorts after the distro defaults, so your rule wins as a local override.
is-enabled should echo back the policy you chose. static would mean the [Install] section is missing. Do not use systemctl --user mask to turn the display off: masking symlinks the unit to /dev/null and blocks all activation, including manual start. Set display.enabled = false in the configuration file instead.
Configuration
The monitor reads $XDG_CONFIG_HOME/ledmatrix-sysmon/config.toml, falling back to ~/.config/ledmatrix-sysmon/config.toml. It is re-read whenever the file changes, so edits take effect within one refresh interval — no restart, no systemctl call.
[display]enabled = true # master switch; false blanks both matricesmode = "gauge" # "gauge", "value", or "icon"interval = 1.0 # seconds between refreshesgauge_labels = false # vertical metric names above the barsfill_from_bottom = true # flip if your bars grow the wrong way
[power]policy = "always" # "always" or "ac_only"battery_off_below = 0 # blank below this charge percentage; 0 disablesresume_on_charge = true # false latches the display off until restartlid_closed_off = true # stop driving the matrices while the lid is shut
[layout]slot1 = "cpu" # cpu, mem, temp, fan, or noneslot2 = "mem"slot3 = "temp"slot4 = "fan"Two further sections, [scale] and [brightness], hold sensor ranges, danger thresholds and per-element brightness. The commented template in the repository documents every key.
Three display modes. gauge draws vertical bars over 34 levels, two side by side per matrix — good for is it moving?. value draws the metric name above its current figure — good for what is the number?. icon keeps that figure but replaces the text label with a pictogram — a chip for CPU, a memory stick for memory, a thermometer for temperature, a fan for fan speed — for a wordless, at-a-glance readout. Switching between them is a one-line edit.
Layout. Slot geometry depends on the mode, because gauge and the two block modes (value, icon) arrange a matrix differently:
gauge mode | value / icon mode | |
|---|---|---|
slot1 | left matrix, left bar | left matrix, top block |
slot2 | left matrix, right bar | left matrix, bottom block |
slot3 | right matrix, left bar | right matrix, top block |
slot4 | right matrix, right bar | right matrix, bottom block |
Gauges sit side by side rather than stacked so each bar keeps all 34 rows; stacking would halve the resolution to 15 levels. Set a slot to "none" to leave that position dark. No metric may occupy two slots — if one does, the whole arrangement reverts to the default rather than silently dropping one.
Power policy. policy = "ac_only" blanks the matrices on battery. battery_off_below = 20 blanks them below 20 % charge; resume_on_charge = true lights them back up once the threshold is cleared or the charger is plugged in, while false latches the display off until the service restarts — which is what “stay off” has to mean, otherwise a 1 % blip would turn everything back on.
When the display goes dark, one blank frame is sent and the monitor stops writing to the serial ports. The modules then fall asleep on their own after 60 s, which is exactly the behaviour you want for saving power.
Closed lid. lid_closed_off = true (the default) stops driving the matrices while the lid is shut, based on /proc/acpi/button/lid/*/state. This is not cosmetic: the Framework 16 pulls the modules’ SLEEP# pin low whenever the lid is closed, and the firmware treats that as a standing instruction — while the pin is asserted the module sleeps and the LED controller is powered down, whatever the host sends. But any command still wakes the device, so a monitor pushing a frame every second wakes the LED controller continuously, only for it to sleep again, behind a closed lid where none of it is visible. In clamshell mode with an external display, that is pure waste. When the lid state cannot be determined, the display stays lit rather than blanking on a guess.
Value and icon modes have no decimals. The matrix is 9 pixels wide, a 3×5 font fits exactly three characters per line, and 100 already uses all nine columns. A decimal point plus one digit would need roughly 13 pixels. Figures are rounded to integers — a physical limit, not a design choice.
Nothing is fatal. A missing file, an unparseable file, an unknown section, a misspelled key, a value of the wrong type, a number out of range, or an inconsistent combination — each falls back to its default and is reported on stderr. The monitor never refuses to start because of its configuration. Cross-field consistency is checked too: temp_min below temp_max, temp_danger inside that range, no duplicate metric across slots.
Start / stop / inspect the service
systemctl --user start ledmatrix-sysmon.servicesystemctl --user stop ledmatrix-sysmon.servicesystemctl --user restart ledmatrix-sysmon.servicesystemctl --user status ledmatrix-sysmon.servicejournalctl --user -u ledmatrix-sysmon.service -fA healthy service shows Active: active (running) with the venv interpreter in the CGroup line. Restart=on-failure lets it retry if a module is not ready yet at startup.
To turn the display off without touching systemd, set enabled = false under [display]. The service keeps running, the matrices go dark, and flipping it back needs no privileges.
Verify
The display is faint at idle by design (low load, fans off, cool die). To prove the pipeline is correct, correlate what the matrices show against ground truth read independently.
Read the EC fans and temperatures two independent ways — the kernel hwmon view and the EC tool — and check they agree:
sensors | grep -iA8 cros_ecsudo framework_tool --thermalRead the same signals the monitor uses:
cat /proc/pressure/cpucat /proc/pressure/memorygrep '^cpu ' /proc/stat ; sleep 1 ; grep '^cpu ' /proc/statfree -hLive load test. Saturate every thread, watch the sensors refresh once per second, and compare with the matrices in real time:
for i in $(seq $(nproc)); do yes > /dev/null & donewatch -n1 'sensors | grep -iE "cros_ec|fan|Tctl|temp"'The CPU gauge should climb immediately; a few seconds later the temperature gauge rises and the fans spin up. Stop the load:
kill $(jobs -p)Force the fan gauge without heating the CPU, to validate the fan reading in isolation:
sudo framework_tool --fansetduty 100sudo framework_tool --thermalsudo framework_tool --autofanctrlWhile duty is at 100 %, the fan gauge should jump to near full; --autofanctrl returns control to the EC and the gauge falls back to zero once the machine is cool.
Hot reload. With the service running, switch display modes and watch the matrices follow within one interval:
sed -i 's/^mode = "gauge"/mode = "value"/' ~/.config/ledmatrix-sysmon/config.tomlPower policy. Set a threshold you are currently above, then unplug — the matrices should go dark, and light back up when you plug in again:
sed -i 's/^battery_off_below = 0/battery_off_below = 95/' ~/.config/ledmatrix-sysmon/config.tomlRemember to set it back to a value you actually want.
Quick all-LED test (independent of the monitor, confirms addressing and hardware):
ledmatrixctl --serial-dev "$(readlink -f /dev/ledmatrix-right)" --percentage 100ledmatrixctl --serial-dev "$(readlink -f /dev/ledmatrix-left)" --percentage 100Troubleshooting
ledmatrixctl: command not found right after pipx install. pipx installed the app correctly but ~/.local/bin is not on your PATH yet. Run pipx ensurepath, then open a new terminal (or exec $SHELL -l) — the running shell will not see the change.
ModuleNotFoundError: No module named 'tkinter' on any ledmatrixctl invocation. framework16-inputmodule 0.1.1 imports its GUI module from cli.py unconditionally, so even --list pulls in PySimpleGUI and therefore tkinter, which Fedora does not ship in the base python3 package:
sudo dnf install -y python3-tkinterThe pipx venv sees the system stdlib, so nothing needs reinstalling afterwards. If you would rather not pull tcl/tk onto the machine, note that ledmatrixctl is only used for diagnosis here — the monitor talks the raw protocol over pyserial and never imports it.
sensors prints ERROR: Can't get value of subfeature fan1_min (and fan1_max, power1_cap). Harmless. libsensors queries every declared sub-attribute; cros_ec_hwmon exposes those without populating them. The *_input values — the only ones this setup uses — are unaffected.
Fans read 0 RPM. Expected on a cool machine: the Framework EC runs a silent fan curve and genuinely stops the fans at idle. Confirm with the load test above rather than assuming the reading is broken.
systemctl --user restart takes 45 seconds to return. Check the journal:
journalctl --user -u ledmatrix-sysmon.service -b | grep -i "timed out"State 'stop-sigterm' timed out means the process did not exit on SIGTERM and systemd waited out its stop timeout before killing it. The cause is a blocked serial write: with the lid closed, SLEEP# is held low, the module sleeps, its USB buffer stops draining, and an unbounded write() never returns — so the signal handler sets its flag but the loop never gets to read it.
Two independent fixes cover this, both already in place: the monitor opens its ports with write_timeout, so writes always return; and lid_closed_off = true stops writing altogether while the lid is shut. TimeoutStopSec=5 in the unit caps the damage if anything else ever blocks.
If you are running an older copy of the script, update it — this was fixed after the original publication.
The matrices stay dark and the service looks healthy. Check the configuration rather than the hardware:
ledmatrix-config-checkThe last line tells you whether the display would be lit right now, and why not — display.enabled = false, a power policy, or a battery threshold.
Replay after a reinstall
Everything under $HOME survives a reinstall that preserves the home partition; everything below does not, and must be recreated:
| Path | Step |
|---|---|
/etc/udev/rules.d/50-framework-inputmodule.rules | 2 |
/etc/udev/rules.d/99-framework-ledmatrix-sides.rules | 3 |
/etc/modules-load.d/cros_ec_hwmon.conf | 4 |
/etc/systemd/user-preset/90-ledmatrix-sysmon.preset | 10 |
Distribution packages are gone too — rerun step 1 first.
The ID_PATH values in step 3 are worth re-reading, but they are a property of the physical bay, not of the install: if you have not moved the modules between bays, they come back identical and the rule can be reused verbatim. Verify rather than assume:
udevadm info -q property -n /dev/ttyACM0 | grep ID_PATH=udevadm info -q property -n /dev/ttyACM1 | grep ID_PATH=fan_max_rpm does not need recalibrating on the same machine — same EC, same fans, same ceiling.
Power consumption
Reference documentation only gives a design ceiling: each Input Module may draw up to 500 mA on the 5 V rail and 100 mA on the 3.3 V rail when active. That says nothing about a real display at real brightness, so it is worth measuring.
Measured on this machine by A/B comparison of battery discharge, display off then on, three independent runs: 0.60 W, 0.65 W and 0.79 W, with a within-run noise floor of 0.11–0.15 W. Call it 0.7 W, or roughly 7 to 9 minutes of runtime over a full charge — under 4 % of idle consumption.
Note what that figure covers: the LEDs themselves, both RP2040 microcontrollers held awake by the refresh, and the monitor waking the CPU on every interval. That last one is not negligible, since a wakeup every second keeps the CPU out of its deeper C-states. Raising display.interval reduces it — 5 s is plenty in value mode.
Measure it on your own machine with measure-ledmatrix-power.sh.
Alternatives
- Pressure instead of utilisation. The CPU and memory gauges default to activity (
cpu_source = "util",mem_source = "used"). Set either to"psi"under[scale]to show Linux PSI pressure (/proc/pressure/*) instead — a contention signal that stays near zero unless the resource is actually saturated. More meaningful, but visually flat most of the time. - Run in the foreground for ad-hoc use:
ledmatrix-sysmon.py(Ctrl-C clears both matrices). Preview the layout with no hardware at all:ledmatrix-sysmon.py --preview. - Start before login.
sudo loginctl enable-linger $USERlets the user service run at boot without a session. Caveat: module access viauaccessis tied to an active seat session, so this may lose access without a login — prefer the login-triggered default. - Out-of-tree EC module (legacy). Before
cros_ec_hwmonwas mainlined, fan/temperature reading required DHowett’sframework-laptop-kmod(DKMS) orframework_toolwith root. Neither is needed on Fedora 44; the mainline driver supersedes them for read-only metrics.
Sources
- ledmatrix-sysmon — monitor, configuration validator and commented template — https://github.com/eloudsa/mylinuxtips/tree/main/framework/ledmatrix
- inputmodule-rs — LED Matrix firmware, protocol and
ledmatrixctl— https://github.com/FrameworkComputer/inputmodule-rs - InputModules — hardware reference designs and the per-module power budget — https://github.com/FrameworkComputer/inputmodules
- framework16-inputmodule (PyPI) — host-side control package and CLI options — https://pypi.org/project/framework16-inputmodule
- Gentoo Wiki, Framework Laptop 16 — input modules and udev rules — https://wiki.gentoo.org/wiki/Framework_Laptop_16
- Phoronix —
cros_ec_hwmonfan/temperature driver mainlined in Linux 6.11, tested on Framework AMD — https://www.phoronix.com/news/Linux-6.11-Chrome-Drivers - Linux kernel documentation —
cros_ec_hwmondriver (fan and temperature readings) — https://www.kernel.org/doc/html/latest/hwmon/cros_ec_hwmon.html - framework-system —
framework_tool --thermal/--fansetduty/--autofanctrlexamples — https://github.com/FrameworkComputer/framework-system/blob/main/EXAMPLES.md
Sources verified on 2026-07-19.