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

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.
  • ledmatrixctl rejects a /dev/... symlink with Failed to find requested device.
  • ledmatrixctl itself fails to start on a clean Fedora with ModuleNotFoundError: No module named 'tkinter', even for --list.
  • Fans read 0 RPM at idle and are not obvious in sensors.
  • 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 the ttyACM index.
  • udev rule ordering. The ID_PATH property is computed by a system rule numbered 60-*. A custom rule that matches on ENV{ID_PATH} must therefore run after it — i.e. its filename must sort after 60- (use 99-). A 51- 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-dev by exact string. A symlink never matches; it must be resolved with readlink -f.
  • The tkinter failure. framework16-inputmodule 0.1.1 imports its GUI module from cli.py unconditionally, so even ledmatrixctl --list pulls in PySimpleGUI, which imports tkinter. Fedora does not ship tkinter in the base python3 package.
  • Fan/temperature reading. Reading the Framework EC’s fans and temperatures is mainline since Linux 6.11 through the cros_ec_hwmon driver — no out-of-tree kernel module is needed. Fans sitting at 0 RPM when the machine is cool is expected behaviour (silent fan curve), not a failure.
  • No module named 'serial' under systemd. mise activates the Python runtime through the shell. A systemd user service does not inherit that activation, so its python3 resolves to the system interpreter, which has no pyserial. The fix is to pin a dedicated virtualenv for the service.

Diagnosis

Check what is already installed:

Terminal window
command -v pipx ledmatrixctl sensors
rpm -q python3-tkinter lm_sensors

Confirm both modules are present:

Terminal window
ledmatrixctl --list

Find the stable physical path of each module (note which ttyACM is which side — light them one at a time if unsure):

Terminal window
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:

Terminal window
sensors | grep -iA6 cros_ec

A 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 mise to provide pyserial to the service — use a dedicated venv (step 6).
  • The udev access rule grants the logged-in user access to the modules; without it ledmatrixctl needs root.

Steps

1. Install the tooling. A clean Fedora Workstation ships none of it.

Terminal window
sudo dnf install -y pipx python3-tkinter lm_sensors
pipx install framework16-inputmodule
pipx ensurepath

pipx ensurepath appends ~/.local/bin to your PATH via userpath, but the current shell does not pick it up. Open a new terminal, or:

Terminal window
exec $SHELL -l
ledmatrixctl --list

python3-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).

Terminal window
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"
EOF
sudo udevadm control --reload && sudo udevadm trigger

3. 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:

Terminal window
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"
EOF
sudo udevadm control --reload && sudo udevadm trigger

Verify:

Terminal window
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.

Terminal window
sensors | grep -iA6 cros_ec

If the cros_ec block is missing, load the driver (it normally auto-loads):

Terminal window
sudo modprobe cros_ec_hwmon
echo cros_ec_hwmon | sudo tee /etc/modules-load.d/cros_ec_hwmon.conf

5. 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

Terminal window
mkdir -p ~/.local/bin ~/.config/ledmatrix-sysmon ~/.config/systemd/user
curl -fsSL -o ~/.local/bin/ledmatrix-sysmon.py https://raw.githubusercontent.com/eloudsa/mylinuxtips/main/framework/ledmatrix/ledmatrix-sysmon.py
curl -fsSL -o ~/.local/bin/ledmatrix-config-check https://raw.githubusercontent.com/eloudsa/mylinuxtips/main/framework/ledmatrix/ledmatrix-config-check
curl -fsSL -o ~/.config/ledmatrix-sysmon/config.toml https://raw.githubusercontent.com/eloudsa/mylinuxtips/main/framework/ledmatrix/config.toml.example
chmod +x ~/.local/bin/ledmatrix-sysmon.py ~/.local/bin/ledmatrix-config-check

The 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):

Terminal window
/usr/bin/python3 -m venv ~/.local/share/ledmatrix-venv
~/.local/share/ledmatrix-venv/bin/pip install pyserial

7. Calibrate the fan scale. Force the fans to full duty, read the RPM, then return to automatic control:

Terminal window
sudo framework_tool --fansetduty 100
sleep 5
sudo framework_tool --thermal
sudo framework_tool --autofanctrl

On 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:

Terminal window
~/.local/share/ledmatrix-venv/bin/python ~/.local/bin/ledmatrix-sysmon.py --list-sensors

You 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.

Terminal window
ledmatrix-config-check

This 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:

Terminal window
~/.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-clear

Run the last one again without --no-clear to blank both matrices.

9. Install the systemd user service.

Terminal window
tee ~/.config/systemd/user/ledmatrix-sysmon.service > /dev/null <<'EOF'
[Unit]
Description=LED Matrix system monitor (Framework 16)
After=graphical-session.target
ConditionPathExists=/dev/ledmatrix-left
[Service]
ExecStart=%h/.local/share/ledmatrix-venv/bin/python %h/.local/bin/ledmatrix-sysmon.py
Restart=on-failure
RestartSec=3
TimeoutStopSec=5
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now ledmatrix-sysmon.service

ConditionPathExists 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:

Terminal window
sudo mkdir -p /etc/systemd/user-preset
sudo tee /etc/systemd/user-preset/90-ledmatrix-sysmon.preset > /dev/null <<'EOF'
enable ledmatrix-sysmon.service
EOF
systemctl --user is-enabled ledmatrix-sysmon.service

Write 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 matrices
mode = "gauge" # "gauge", "value", or "icon"
interval = 1.0 # seconds between refreshes
gauge_labels = false # vertical metric names above the bars
fill_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 disables
resume_on_charge = true # false latches the display off until restart
lid_closed_off = true # stop driving the matrices while the lid is shut
[layout]
slot1 = "cpu" # cpu, mem, temp, fan, or none
slot2 = "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 modevalue / icon mode
slot1left matrix, left barleft matrix, top block
slot2left matrix, right barleft matrix, bottom block
slot3right matrix, left barright matrix, top block
slot4right matrix, right barright 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

Terminal window
systemctl --user start ledmatrix-sysmon.service
systemctl --user stop ledmatrix-sysmon.service
systemctl --user restart ledmatrix-sysmon.service
systemctl --user status ledmatrix-sysmon.service
journalctl --user -u ledmatrix-sysmon.service -f

A 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:

Terminal window
sensors | grep -iA8 cros_ec
sudo framework_tool --thermal

Read the same signals the monitor uses:

Terminal window
cat /proc/pressure/cpu
cat /proc/pressure/memory
grep '^cpu ' /proc/stat ; sleep 1 ; grep '^cpu ' /proc/stat
free -h

Live load test. Saturate every thread, watch the sensors refresh once per second, and compare with the matrices in real time:

Terminal window
for i in $(seq $(nproc)); do yes > /dev/null & done
watch -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:

Terminal window
kill $(jobs -p)

Force the fan gauge without heating the CPU, to validate the fan reading in isolation:

Terminal window
sudo framework_tool --fansetduty 100
sudo framework_tool --thermal
sudo framework_tool --autofanctrl

While 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:

Terminal window
sed -i 's/^mode = "gauge"/mode = "value"/' ~/.config/ledmatrix-sysmon/config.toml

Power policy. Set a threshold you are currently above, then unplug — the matrices should go dark, and light back up when you plug in again:

Terminal window
sed -i 's/^battery_off_below = 0/battery_off_below = 95/' ~/.config/ledmatrix-sysmon/config.toml

Remember to set it back to a value you actually want.

Quick all-LED test (independent of the monitor, confirms addressing and hardware):

Terminal window
ledmatrixctl --serial-dev "$(readlink -f /dev/ledmatrix-right)" --percentage 100
ledmatrixctl --serial-dev "$(readlink -f /dev/ledmatrix-left)" --percentage 100

Troubleshooting

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:

Terminal window
sudo dnf install -y python3-tkinter

The 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:

Terminal window
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:

Terminal window
ledmatrix-config-check

The 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:

PathStep
/etc/udev/rules.d/50-framework-inputmodule.rules2
/etc/udev/rules.d/99-framework-ledmatrix-sides.rules3
/etc/modules-load.d/cros_ec_hwmon.conf4
/etc/systemd/user-preset/90-ledmatrix-sysmon.preset10

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:

Terminal window
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 $USER lets the user service run at boot without a session. Caveat: module access via uaccess is 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_hwmon was mainlined, fan/temperature reading required DHowett’s framework-laptop-kmod (DKMS) or framework_tool with root. Neither is needed on Fedora 44; the mainline driver supersedes them for read-only metrics.

Sources

Sources verified on 2026-07-19.