A graphical dashboard for the small LCD panel built into the AOOSTAR WTR MAX NAS.
Instead of scrolling numbers, the 960×376 panel shows bar charts with colour thresholds, five-hour area charts of CPU / GPU / RAM usage, and pages for network and storage. It runs as a systemd service, talks to the panel directly over its USB serial port, and needs nothing but Python 3 and two common libraries.
- Written in Python 3 — no compiler, no toolchain, no Rust at runtime
- Two dependencies: Pillow (drawing) and pyserial (the panel)
- Works on bare metal or from inside a Proxmox LXC container
- Every page is optional and reorderable; pages with no data hide themselves
- Screens are driven by one JSON file — colours, thresholds, labels, durations
Naming: the repository is
nas-mini-display; the Python package and the systemd unit inside it are calledaoo-display, from the AOOSTAR panel they were written for.
The rotation below is the default. Each page can be removed, reordered or re-timed in
config.json, and any page whose data is unavailable is skipped automatically — no GPU
sensor means no GPU page.
These images are produced by the renderer itself, from real readings on a WTR MAX. Only
the multi-hour history behind the area charts is simulated: a screenshot is a single
moment, so there is no other way to show a five-hour chart. Regenerate them any time
with python3 tools/render_docs_screens.py.
Worst reading of each group, so one hot drive cannot hide behind three cool ones.
One bar per device, so you can tell which drive is the warm one.
CPU, GPU and RAM show temperature and utilisation side by side. If your CPU exposes per-core temperatures, a per-core page appears as well.
The fill is a fixed vertical gradient — green at the bottom, red at the top — so the height of the curve can be read from its colour alone. History is written to disk, so the charts survive restarts instead of starting empty every time.
| Group | blue | yellow | red |
|---|---|---|---|
| HDD, NVMe, RAM | ≤ 60 °C | 60 – 80 °C | > 80 °C |
| CPU, GPU | ≤ 75 °C | 75 – 88 °C | > 88 °C |
The change is abrupt at the threshold and gradual inside each band, so a glance tells you which band a value is in. All of it is redefinable — see docs/configuration.md.
- An AOOSTAR WTR MAX (or any machine wired to the same
0416:90A1USB serial panel) - Linux with systemd, Python 3.10 or newer
python3-pil,python3-serial;smartmontoolsonly if you need drive temperatures that thedrivetempkernel module cannot provide
Nothing needs to be compiled and no daemon other than this one is added.
Pick the path that matches where the USB panel is visible.
The simplest case: the operating system that owns the disks also owns the panel.
git clone https://github.com/teoteo/nas-mini-display.git
cd nas-mini-display
sudo ./install.shinstall.sh installs the dependencies, copies the code to /opt/aoo-display, loads the
drivetemp module (SATA drive temperatures) and makes it persistent, disables the
stock aster-display.service if present — both cannot share the serial port — and
starts aoo-display.service.
Then check what was detected and adjust the config:
python3 -m aoo_display.app --config /opt/aoo-display/config.json --diagnose
sudo nano /opt/aoo-display/config.json
sudo systemctl restart aoo-displayOn bare metal you usually only need to fill in sensors.volumes with the mount points
you want on the storage page, and rename drives in sensors.labels.
Common on these boxes: Proxmox owns the hardware and a small container owns the panel, because that is where the USB device was passed through.
A container sees more than you might expect and less than you need. /sys is shared
with the host, so temperatures are real — drives, CPU, GPU, memory modules all
report correctly. But /proc is masked by lxcfs, so CPU and memory usage are the
container's, not the machine's, and the host's storage pools are invisible.
Install exactly as in Path A, inside the container. Then close the two gaps by giving the container a read-only Proxmox API token — no software is installed on the host, no bind mount, no container restart:
# on the Proxmox host, once
pveum user add display@pve --comment "LCD display (read-only)"
pveum acl modify /nodes/$(hostname) --users display@pve --roles PVEAuditor
pveum acl modify /storage --users display@pve --roles PVEAuditor
pveum user token add display@pve lcd --privsep 0The last command prints the secret once. Put it in the container's config:
"sensors": {
"proxmox": {
"enabled": true,
"host": "192.168.1.10",
"node": "proxmox",
"token_id": "display@pve!lcd",
"token_secret": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"use_for": ["volumes", "cpu_usage", "ram_usage", "ram_bytes",
"uptime", "load", "network", "hostname"]
}
}chmod 600 /opt/aoo-display/config.json
python3 -m aoo_display.app --config /opt/aoo-display/config.json --diagnose
systemctl restart aoo-displayuse_for decides which values come from the API; drop an entry and that value stays
local. A ready-made file is in
examples/config.proxmox-lxc.json, and the details —
including two alternatives that do not use the API — are in
docs/proxmox.md.
The token sits in a plain file, so keep it at chmod 600 and give it no role beyond
PVEAuditor, which is read-only.
# what the system can see, with hints about anything missing
python3 -m aoo_display.app --config config.json --diagnose
# solid colours, a test card, then a partial update — isolates the panel from the app
python3 -m aoo_display.app --test-pattern
# one PNG per page, without touching the panel
python3 -m aoo_display.app --config config.json --preview /tmp/screens
# turn the panel off and on
python3 -m aoo_display.app --off
python3 -m aoo_display.app --on
journalctl -u aoo-display -f--diagnose is the first thing to run when something is missing: it lists hwmon chips,
block devices, available tools and the Proxmox connection, then prints concrete
suggestions for whatever it could not read.
Nothing on the panel, no errors in the log. Another process owns the serial port.
systemctl stop aster-display.service, or whatever else talks to the panel.
The panel keeps showing an old image while the log reports chunks being sent. The
firmware needs one command per USB packet. chunk_batch must be 1 unless you have
tested otherwise with --test-pattern.
No drive temperatures. modprobe drivetemp, then check --diagnose again. On
drives the module cannot read, set "smartctl": true — but list USB drives in
sensors.hide first, or polling will keep waking them up.
Drives, CPU or GPU missing inside a container. /sys may not be fully visible.
--diagnose says which one is missing and what to do about it.
The whole dashboard is one JSON file. The keys you are most likely to touch:
| Key | Effect |
|---|---|
pages[] |
which screens appear, in what order, for how long (seconds) |
display_on_hour / display_off_hour |
switch the panel off overnight |
graph.window_hours |
width of the area charts (default 5) |
sensors.volumes |
mount points on the storage page |
sensors.labels |
rename drives: {"sda": "HDD1", "nvme0": "System"} |
sensors.hide |
exclude devices entirely |
scales |
thresholds and colours |
decimals, decimal_comma |
number format (52.7 or 52,7) |
Full reference: docs/configuration.md.
Roughly 2,600 lines of Python in eleven modules:
lcd.py |
the panel protocol: RGB565, 47-byte chunks, differential updates |
sensors.py |
readings from /sys, /proc, smartctl, external files |
pve.py |
Proxmox API client, on its own thread |
draw.py |
drawing primitives: rounded gradient bars, area charts |
pages.py |
the screens |
theme.py |
palette, threshold scales, geometry |
history.py |
persistent history for the charts |
app.py |
configuration, scheduler, main loop |
Two decisions shaped the rest. Frames are drawn on a 1920×752 canvas and scaled down
to 960×376, which is the cheapest way to get antialiased curves and rounded corners
out of Pillow, at about 40 ms per frame. And RGB565 conversion goes through
Image.merge("LA", …), which packs two bytes per pixel at C speed: 7 ms, byte-identical
to the numpy version, without the numpy dependency.
After the first full frame only changed chunks are retransmitted, so a typical update is a few kB instead of 900, and a full refresh is forced every five minutes in case the panel's framebuffer drifts out of sync. See docs/protocol.md for what we learned about the wire format.
Tested only on the AOOSTAR WTR MAX, in the configuration described above. That is the honest scope: one machine, one panel, verified in daily use.
Nothing in the design is specific to that model, though. Hardware discovery is
automatic — the serial port is found by USB VID:PID, drives, temperatures and GPU come
from /sys without any model knowledge — so another WTR MAX should work with no changes
beyond labels and mount points.
The GEM12+ PRO uses the same panel according to upstream, so it is the most likely next candidate; it has not been tested here.
For a panel from another vendor, the display-specific parts are the constants at the top
of lcd.py: resolution, VID:PID, baud rate and the command bytes. Everything above that
layer is independent of the screen.
Reports from other hardware are welcome — a --diagnose output is the most useful thing
to attach.
The USB protocol of this panel is not documented by the manufacturer. It was reverse
engineered by Markus Zehnder and Gabriel Max in
aoostar-rs
(documentation), whose
docs/lcd_protocol.md and asterctl-lcd crate are where the command bytes, the 47-byte
chunking and the framing in this project come from. Without that work this dashboard
would not exist, and their asterctl remains the reference implementation.
This is an independent Python implementation, not a port of their code, and it runs
without any part of aoostar-rs installed. It does keep optional support for their
aster-sysinfo collector as an alternative data source.
Dual licensed, matching upstream, at your option:
Unless you state otherwise, any contribution you submit shall be dual licensed as above, without additional terms or conditions.










