benchpi, a touchscreen status panel for a Raspberry Pi 5 with a 480x320
ILI9486 SPI LCD hat, running on the bare Linux framebuffer. No X11, no
Wayland, no compositor, no browser.
+--------------------------------------------------+
| benchpi eth0 192.0.2.17 |
| +------------+ +------------+ +----------------+ |
| | 49.0C | | 13% | | 0.03 | |
| | CPU TEMP | | MEMORY | | LOAD | |
| +------------+ +------------+ +----------------+ |
| +----------------------------------------------+ |
| | USB up 22m | |
| | CH341A programmer | |
| | FT232R USB UART | |
| +----------------------------------------------+ |
| [ REBOOT ] [ SHUTDOWN ] |
+--------------------------------------------------+
| Part | Detail |
|---|---|
| Board | Raspberry Pi 5, 2GB, Debian 13 (trixie), Linux 6.18 |
| Display | /dev/fb0, fb_ili9486, 480x320, RGB565, 960 byte stride |
| Touch | /dev/input/event1, ADS7846/XPT2046, spi0.1 |
Touch orientation is corrected in the device tree with swapxy,invy, so the
evdev coordinates already line up with the landscape framebuffer.
The user running it needs the video and input groups. The app itself
never needs root.
LVGL is not packaged in Debian, so the Makefile fetches the release tarball. It is not checked in.
make lvgl # download LVGL 9.5.0 into ./lvgl
make -j4 # ~700 translation units, under 30 seconds on a Pi 5make lvgl has to run first: the source list is a find expanded when the
Makefile is parsed.
Take the kernel console off the framebuffer first, or it will corrupt the UI. See "The console owns fb0 too" below for why.
sudo systemctl disable --now gpm # reads the touchscreen as a mouse
echo 0 | sudo tee /sys/class/vtconsole/vtcon1/bind
./benchpiecho 1 to that same file gives the console back.
The REBOOT and SHUTDOWN buttons additionally need a sudoers drop-in, or they
will display NO SUDO and do nothing:
sed "s/YOURUSER/$USER/" benchpi.sudoers \
| sudo install -o root -g root -m 0440 /dev/stdin /etc/sudoers.d/benchpiThe sed fills in whoever runs the dashboard. It grants exactly
systemctl poweroff and systemctl reboot, and nothing else. It is needed
because polkit treats the dashboard's session as remote, having no seat, and
demands authentication for org.freedesktop.login1.power-off, which a
touchscreen cannot supply. pkcheck says so directly:
$ pkcheck --action-id org.freedesktop.login1.power-off --process $$
Authorization requires authentication and -u wasn't passed.
Running the whole UI as root to avoid that one file would be a much worse trade.
sed "s/YOURUSER/$USER/g; s|YOURHOME|$HOME|" benchpi.service \
| sudo tee /etc/systemd/system/benchpi.service
sudo systemctl enable --now benchpiThe unit runs the UI unprivileged and unbinds the console in an
ExecStartPre=+ line, which systemd runs as root regardless of User=. The
manual echo 0 > .../vtcon1/bind does not survive a reboot, so without this
the LCD comes back up showing a login prompt.
Stopping the service rebinds the console, so the panel becomes a terminal again whenever the dashboard is not running.
sudo cp 99-ch341a.rules /etc/udev/rules.d/
sudo udevadm control --reload && sudo udevadm triggerflashrom's ch341a_spi programmer drives the device over libusb, so it needs
write access to the /dev/bus/usb node rather than a tty. No kernel driver
binds 1a86:5512, so there is nothing to unbind first. flashrom is already
installed at /usr/sbin/flashrom, which is not on a non-root PATH here.
Hostname, the active IPv4 address and which interface it is on, CPU
temperature, memory used, 1 minute load average, uptime, and the product
strings of every attached USB device that is not a root hub. Everything comes
from sysfs, procfs and getifaddrs(); there are no runtime dependencies
beyond libc and LVGL, and nothing is shelled out to.
IPv4 only, and interface-agnostic. A Pi with DHCP reservations on both
interfaces still usually has only one of them up, so the panel takes whichever
holds an address and prefers the wired one rather than hardcoding eth0 or
wlan0. IPv6 is deliberately ignored, because nobody is reading a SLAAC
address off a 3.5 inch display.
Labels are only rewritten when their text actually changes. Every redraw is real milliseconds on a 32 MHz SPI panel, so a 1 Hz refresh of unchanged values costs nothing.
REBOOT and SHUTDOWN arm on the first tap and fire on the second, reverting after 3 seconds. A resistive touchscreen on a bench picks up stray contact, and one brush past the panel should not power the machine off.
Idle cost is about 2.8 MB RSS and 0.0% CPU.
- LVGL 9 talks to the framebuffer through
lv_linux_fbdev_create()/lv_linux_fbdev_set_file()and to the touchscreen throughlv_evdev_create(). The LVGL 8 display/input driver API is gone; examples written for it will not compile. lv_conf.honly lists the settings we change.lv_conf_internal.hsupplies a default for every option we leave out.fbtftpushes the framebuffer to the panel over SPI via deferred IO. If updates do not appear, trylv_linux_fbdev_set_force_refresh(disp, true), or setLV_LINUX_FBDEV_MMAP 0to usepwrite()instead ofmmap.
vtcon1 is bound to the same framebuffer, and it will corrupt the UI. What we
saw: large rectangles of the screen reverting to pure black, with only the
regions LVGL had recently invalidated still showing the right colours.
The cause is that fbtft does not implement fb_blank. When the console
blanks the display it therefore falls back to software blanking, which means
painting black straight into the framebuffer. /sys/class/graphics/fb0/blank
reads 4 (FB_BLANK_POWERDOWN) while the panel is visibly showing a UI.
LVGL notices and tries to undo it, which is what this startup warning is:
ioctl(FBIOBLANK): Invalid argument
That is LVGL issuing FB_BLANK_UNBLANK, and fbtft answering EINVAL. It is
harmless in itself, but it means LVGL cannot recover the screen on its own.
Because the default render mode is LV_DISPLAY_RENDER_MODE_PARTIAL, LVGL only
repaints areas it has invalidated, so the blacked-out regions stay black.
Note that consoleblank=0 on the kernel command line does not prevent this.
gpm is a second offender. It ships enabled on this image, running
gpm -m /dev/input/mice -t exps2, and /dev/input/mice aggregates mouse0,
which is the ADS7846. So it reads the touchscreen as a mouse and paints a
console cursor onto the framebuffer.
- The journal is not persistent.
/var/log/journal/exists but is empty, so journald writes to/runand every reboot destroys the evidence. Worth fixing before chasing any crash:sudo mkdir -p /var/log/journal && sudo systemctl restart systemd-journald systemdarms the BCM2835 hardware watchdog with a 60 second timeout, so anything that wedges PID 1 is a hard reset with no log at all.sudohere is not passwordless. The RPi image setsDefaults timestamp_type=globalin/etc/sudoers.d/010_global-tty, so asudoin any session warms the credential cache for every other session by the same user.- To screenshot the panel, dump the framebuffer and decode RGB565:
cat /dev/fb0 > fb.rawgives 307200 bytes, 480x320 at 2 bytes per pixel with no stride padding. Counting distinct pixel values is a fast corruption check; a clean screen has well under a hundred. - The display is
spi0.0and the touchscreen isspi0.1, sharing one SPI bus at 32 MHz withfps=30.