A lightweight system monitor for Raspberry Pi with Waveshare e-Paper display, written in Zig.
- Real-time Monitoring: CPU load, temperature, memory usage, disk usage, fan speed.
- Network Stats: IP address, signal strength (WiFi), upload/download speeds.
- System Info: Uptime, APT updates availability, Internet connection status.
- Hardware Health: Under-voltage and NVMe SMART critical warnings — shown as an inverted status bar on the panel and published over MQTT as problem entities.
- Optimized Rendering: Partial updates for e-Paper display to minimize flickering and maximize refresh rate.
- Home Assistant: Optional MQTT publishing with auto-discovery.
- Notices: Any program can put a short message on the panel for a while, through a named pipe locally, or an MQTT topic or the HTTP API from anywhere on the network.
- HTTP API: Current readings as JSON, and notices with a bearer token, served beside the optional panel preview.
- Standalone: Statically linked binary (using musl), easy to deploy on any distro.
| Main Screen | Loading Screen |
|---|---|
![]() |
![]() |
| Sleep Screen | Threshold Alert |
|---|---|
![]() |
![]() |
- Raspberry Pi: Tested on Pi 5; uses the modern Linux GPIO character device
API (
/dev/gpiochip*), including the Pi 5's RP1 chip. Older models may work —armhfbuilds are published and target ARMv6, so they also run on the Pi Zero, Zero W and Pi 1 — but are untested. - Display: Waveshare 2.9inch e-Paper Module (B/W) V2.
- Note: This project is specifically tuned for the V2 version of the display.
- Connections: SPI interface and GPIO pins (RST, DC, BUSY, PWR).
The daemon needs the panel to start: the first thing it does is open the GPIO
chip and the SPI device, and it exits if it cannot. So it runs on the
Raspberry Pi and nowhere else, and EXPORT_BMP=true does not change that — it
copies each frame the panel is sent to a file, on a machine that has a panel.
To work on the UI without the hardware, use the panel simulator, which runs the same renderer, fonts and layout with nothing behind them:
zig build simEverything else that needs no panel is covered by the tests below, and Build Instructions has the cross-compile for the Pi.
Parsing, layout, rendering, the panel driver, the MQTT protocol and the HTTP API's request reading, routing and token check are covered by unit tests that run on any host — the renderer needs no hardware at all, and the driver and the panel state machine above it are generic over their transport, so their command sequences are checked against a recorder rather than a panel:
zig build test --summary allOn Linux the same command also runs the daemon's HTTP server on a loopback port and talks to it as a client would, and reads notices through a real named pipe. Neither needs a panel, but both make Linux system calls, so other hosts leave them out.
The remaining hardware-facing modules only compile for Linux, so they are type-checked separately — once for each target a release is built for, the 32-bit ARMv6 of a Pi Zero included, since what compiles for one of them does not have to for another:
zig build checkThe renderer has a golden-image test: it draws a screen with fixed values and
compares the packed frame against src/testdata/golden_main.bin, which pins the
whole layout at once. After an intentional layout change, regenerate it and
review the diff:
zig build goldenThe daemon itself only runs on Linux, but the whole rendering path is host- independent: the renderer only produces frames and never touches the panel, which is what the golden test already relies on. The simulator runs that same renderer and shows the result, so layout work needs no hardware:
zig build simOn macOS that opens a native window. Elsewhere — and with zig build sim-web
anywhere — it serves the frame at http://127.0.0.1:8390 instead. Both show the
panel magnified 3x with pixels left unsmoothed, and both go through the same
export the daemon uses, so the fault overlay appears exactly as it would on the
glass.
Metrics are synthesized as smooth functions of time, which walks every slot
through its own formatting without anyone driving it: traffic sweeps several
orders of magnitude so each unit appears, the signal reading crosses the width
where it drops its dBm suffix, and the fault overlay flares for six seconds
out of every thirty so the inverted status bar can be checked too. For another
six the disk temperature, the traffic rates and the APT count go
stale, so the faded rendering can be as well.
src/font_data.zig is generated by tools/fontgen.c (needs cairo and freetype2).
Glyph bitmaps depend on the installed cairo/FreeType versions, and the layout
constants in src/display_config.zig are tuned to the checked-in output — diff
carefully before committing a regenerated file.
gcc -o fontgen $(pkg-config --cflags cairo freetype2) tools/fontgen.c $(pkg-config --libs cairo freetype2) -lm
./fontgen # writes src/font_data.zigsrc/main.zig: Entry point: reads the configuration, puts the parts together, handles signals and runs the main event loop.src/app.zig: The daemon's tasks — take the readings, draw them, put the frame on the panel, pass them on. Generic over the panel's transport, so all of it runs under test against a recorder and a tree of fixtures.src/app_logic.zig: What those tasks decide, apart from the I/O that carries it out — when a refresh is a full one, which fault just appeared, what goes to the broker (unit tested).src/scheduler.zig: Monotonic-clock task scheduler for periodic updates.src/parse.zig: I/O-free parsers for procfs/sysfs and command output (unit tested).src/reading.zig: A reading together with how its latest measurement went: current, stale or unavailable.src/display_renderer.zig: High-level rendering logic (text, icons, fault overlay). Produces frames; knows nothing of the hardware.src/panel.zig: Puts frames on the glass: partial or full refresh, deep sleep between updates, and the reference frame partial updates diff against.src/display_config.zig: Where everything goes on the panel, one section of the dashboard at a time: its heading, its fixed icons, and a slot per reading.src/graphics.zig: Bitmap drawing primitives and text rendering.src/font_data.zig: Generated static glyph tables.src/system_ops.zig: System metrics collection (CPU, RAM, disk, SMART, under-voltage).src/sysroot.zig: The directory procfs and sysfs are read under —/on a machine, a tree of fixtures in the tests ofsystem_opsandnetwork_ops.src/gpio_native.zig: GPIO character device access (v2 ABI).src/ioctl.zig: ioctl request numbers, worked out from the struct each one takes as the kernel's_IOWand_IOWRdo, for GPIO, SPI and NVMe alike.src/bounded_connect.zig: TCP connect and send-buffer wait with a deadline, shared by MQTT and the reachability probe untilstdimplementsConnectOptions.timeout.src/network_ops.zig: Network status and traffic monitoring.src/mqtt.zig: MQTT 3.1.1 client and Home Assistant discovery.src/notice.zig,src/notice_fifo.zig: notices — reading the payload, folding its text to what the fonts can draw, deciding when one goes up and comes down, and receiving it through the named pipe. MQTT notices arrive throughsrc/mqtt.zig, HTTP ones throughsrc/web_preview.zig.src/bmp.zig: BMP export for headless preview.src/config.zig,src/logger.zig: Configuration and logging. The configuration is read from the environment once, into a value thatmainhands on; nothing else reads it. Every setting goes through one reader, the broker's included, which is also what knows a misspelt name from a stranger.src/waveshare_epd/: Low-level driver for the e-Paper display, generic over its transport;fake_transport.zigis the recorder the tests drive it andsrc/panel.zigwith.src/syscall.zig: Interpreting raw Linux syscall returns (see the module comment).src/tests.zig,src/golden_gen.zig: test root and the golden-frame generator.src/web_preview.zig: optional HTTP view of the current frame and the API, off by default.src/api.zig,src/http_request.zig: the API's routes, token check and status JSON, and reading a request — both free of I/O and unit tested.src/frame_server.zig,src/viewer_page.html: the HTTP serving and the viewer page, shared by the preview and the simulator.src/sim_frame.zig: what the simulators draw; the front ends aresrc/sim_native.zig(macOS window) andsrc/sim_web.zig(served preview).
- Zig Compiler 0.17.0
- Network access on the first build:
zig buildfetches the translate-c package pinned inbuild.zig.zon, which turns the libc headers insrc/c.hinto Zig.
To build a statically linked binary for Raspberry Pi:
zig build -Dtarget=aarch64-linux-musl -Doptimize=safeThe resulting binary will be located at zig-out/bin/sys-ink.
safe (formerly ReleaseSafe) is what the releases are built with: it keeps
the bounds and overflow checks in, so a bug in reading something off the
network ends the daemon — which systemd restarts — instead of corrupting the
memory of a process that runs as root. small builds too and is about 200 KB
smaller, without them.
For 32-bit Raspberry Pi OS, name the CPU as well. Zig's default for this target is ARMv7, which the Pi Zero, Zero W and Pi 1 cannot run; the ARM1176 build below runs on every 32-bit Pi:
zig build -Dtarget=arm-linux-musleabihf -Dcpu=arm1176jzf_s -Doptimize=safeThe easiest way to install and keep SysInk updated is using our APT repository.
-
Add the signing key and repository:
sudo mkdir -p /etc/apt/keyrings sudo curl -fsSL -o /etc/apt/keyrings/sys-ink.gpg https://zales.github.io/sys-ink/sys-ink-archive-keyring.gpg echo "deb [signed-by=/etc/apt/keyrings/sys-ink.gpg] https://zales.github.io/sys-ink/ ./" | sudo tee /etc/apt/sources.list.d/sys-ink.list
The repository is signed. You can check the key you just installed against the published fingerprint:
gpg --show-keys /etc/apt/keyrings/sys-ink.gpg # C834 8411 213F 794E 2773 8104 82A9 AAFF 9189 5891Upgrading from an unsigned install. Releases before 1.5.0 were published unsigned, and the instructions asked for
[trusted=yes], which disables package authentication entirely. Run the two commands above to replace that line;aptwill then verify what it downloads. -
Update and Install:
sudo apt update sudo apt install sys-ink
The service will start automatically after installation.
- Download the
.debpackage for your architecture (arm64for 64-bit Raspberry Pi OS,armhffor 32-bit) from the Releases page. - Install:
The service will start automatically.
sudo dpkg -i sys-ink_*.deb
- Download the binary (
sys-ink-aarch64orsys-ink-armhf) from the Releases page. - Transfer to Raspberry Pi:
scp sys-ink-aarch64 user@raspberrypi:/usr/local/bin/sys-ink
- Set Permissions:
Ensure the user running the application is in
ssh user@raspberrypi sudo chmod +x /usr/local/bin/sys-ink
gpioandspigroups.
The .deb packages ship their own unit. If you installed manually, create:
/etc/systemd/system/sys-ink.service:
[Unit]
Description=SysInk Service
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/sys-ink
Restart=always
RestartSec=5
User=root
EnvironmentFile=-/etc/default/sys-ink
NoNewPrivileges=yes
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes
RuntimeDirectory=sys-ink
[Install]
WantedBy=multi-user.targetEnable and start the service:
sudo systemctl enable --now sys-inkThe hardening lines are the ones the .deb unit uses. They leave the devices,
/var and /tmp reachable, which the panel, the APT check and the BMP export
need, and close off home directories and the kernel's tunables. With
ProtectHome, BMP_EXPORT_PATH and LOG_FILE_PATH cannot point into /home
or /root. systemd-analyze security sys-ink shows what is left open.
RuntimeDirectory gives the notice pipe its /run/sys-ink and removes it when
the service stops.
The application is configured via environment variables. The .deb package reads
them from /etc/default/sys-ink; otherwise set them in the unit file or export
them before running.
A variable left blank — MQTT_USERNAME= — counts as unset, whichever one it
is: it reads as if the line were not there.
A variable set to something that cannot be used — INTERVAL_FAST=abc, a port
out of range, a misspelt true — is never passed over in silence. The daemon
starts anyway, and says in its log what it turned down and what it is using
instead:
[12:00:01] [warn] (main) Ignoring INTERVAL_FAST=abc: expected a whole number from 1 to 4294967295; using 30
Neither is a name that is not a setting but was probably meant to be one: a
letter or two away from one, or beginning as a group of them does
(THRESHOLD_, MQTT_, LOG_, …) without being any of them. The log gives the
name only, never the value, since a misspelt MQTT_PASSWORD is exactly what
turns up here:
[12:00:01] [warn] (main) Ignoring INTERVL_FAST: not a setting; did you mean INTERVAL_FAST?
[12:00:01] [warn] (main) Ignoring THRESHOLD_CPU_HIGH: not a setting this daemon has
Variables that are none of the daemon's business — PATH, another program's
settings in the same file — are left alone.
| Variable | Default | Description |
|---|---|---|
GPIO_CHIP |
auto-detected | Path to GPIO chip device. Auto-detected from known pinctrl chip labels, falling back to /dev/gpiochip0. Check with gpiodetect. |
SPI_DEVICE |
/dev/spidev0.0 |
Path to SPI device |
| Variable | Default | Description |
|---|---|---|
INTERVAL_FAST |
30 |
Seconds between CPU/memory/disk/fan/traffic/uptime/IP updates and display refreshes |
INTERVAL_SLOW |
10800 |
Seconds between APT and internet-reachability checks (3 hours). With MQTT enabled the reachability probe also runs for Home Assistant, at most once a minute, and the panel shows the newest result |
INTERVAL_FULL_REFRESH |
600 |
Seconds between full panel refreshes, which clear the ghosting left by partial updates. A full refresh flashes the panel by design; raise this if that bothers you, at the cost of more accumulated ghosting |
Network rates use decimal units: kB is 1000 bytes, matching the SI prefix and
the convention for throughput. Releases before 1.5.0 divided by 1024 while
labelling the result kB, so displayed and published rates were 2.4% lower than
the unit claimed.
Only interfaces backed by hardware — Ethernet, Wi-Fi, USB adapters — are counted. Bridges, veth pairs and VPN tunnels carry the same bytes a second time, so on a host running Docker every container's traffic used to be counted two or three times over. Where no interface has a device, as inside a container, all of them are summed as before.
| Variable | Default | Description |
|---|---|---|
PANEL_SLEEP |
true |
Park the panel in deep sleep between refreshes instead of leaving it driven continuously, which Waveshare advises against. Cycles where nothing changed then cost nothing at all. Adds roughly 200 ms to each visible update, and does not add any flashing — waking is done without driving the panel. Set to false to keep the controller powered. |
Waking from deep sleep loses the reference frame that partial updates are
rendered against, so it is restored from the frame the daemon knows is on the
glass. That restore only takes effect if it happens before the partial update
sequence powers the analog stage up; see primeBase in
src/waveshare_epd/epd2in9.zig. Getting this wrong smears the previous
content rather than failing outright, which is why it is spelled out here.
Values at or above these are rendered inverted.
| Variable | Default | Description |
|---|---|---|
THRESHOLD_CPU_CRITICAL |
90 |
CPU load critical threshold (%) |
THRESHOLD_TEMP_CRITICAL |
85 |
CPU and disk temperature critical threshold (°C) |
THRESHOLD_MEM_CRITICAL |
95 |
Memory usage critical threshold (%) |
THRESHOLD_DISK_CRITICAL |
95 |
Disk usage critical threshold (%) |
Every reading is in one of three states, and the panel, the API and MQTT each say which:
| State | Meaning | Panel | /api/status |
MQTT |
|---|---|---|---|---|
| Current | The latest attempt measured it | The value | The value | Published |
| Stale | The latest attempt failed; the value is an earlier one's | The value, faded | The value, with its age in seconds under stale |
Withheld |
| Unavailable | No such sensor, or nothing measured yet | - |
null |
Withheld |
A reading that fails once keeps its last value rather than blanking its slot,
but no longer passes it off as live: before, a disk temperature whose sensor
had stopped answering stayed on the panel and went out over MQTT every cycle
for as long as the daemon ran. Withheld from MQTT, the entity goes unavailable
in Home Assistant after three INTERVAL_FAST periods like any other that
stops updating.
The APT count is stale when the latest check failed, which makes it at least
INTERVAL_SLOW old. The uptime, the IP address and the Wi-Fi signal are never
stale: one that cannot be read is dropped at once. The fault bar goes by what
under-voltage and SMART last reported, stale or not — failing to read a sensor
is no evidence that the fault it reported has cleared.
| Variable | Default | Description |
|---|---|---|
INTERNET_CHECK_IP |
8.8.8.8 |
IPv4 address probed with a 1-second TCP connect |
INTERNET_CHECK_PORT |
53 |
Port for the probe |
The running daemon can serve the frame currently on the panel, which is the only way to see it on a machine you are not standing next to.
| Variable | Default | Description |
|---|---|---|
WEB_PREVIEW |
false |
Serve the current frame over HTTP, and the HTTP API beside it |
WEB_PREVIEW_ADDR |
127.0.0.1 |
Address to bind |
WEB_PREVIEW_PORT |
8390 |
Port to bind |
What it serves is the frame the daemon drew, not a re-render, so it agrees with the glass including the fault overlay.
It has no authentication, and the frame shows the host's address, load and disk figures. It binds loopback, so viewing it from elsewhere means a tunnel:
ssh -L 8390:127.0.0.1:8390 user@raspberrypi
WEB_PREVIEW_ADDR=0.0.0.0puts it on the network for anyone who can reach the port. Set that only on a network where that is acceptable.
Reading the request carries a two-second deadline, so a client that connects and then says nothing costs one pause rather than taking the preview down — which is what used to happen. Writes have no deadline of their own and need none: a response is a few kilobytes and fits the socket's send buffer whole, so it never waits on a client that has stopped reading. Failed accepts back off instead of spinning.
One connection is served at a time, which is ample for a page fetching one image a second but does mean many simultaneous stalled connections slow it down, each for up to its deadline. Nothing recovers from that but waiting, so treat the preview as a diagnostic aid on a network you trust rather than a service to expose.
The preview's server also answers a small API, so it is on whenever
WEB_PREVIEW is. docs/openapi.yaml describes it in
full as an OpenAPI 3.1 document.
| Method | Path | What it does |
|---|---|---|
GET |
/api/status |
The latest readings as JSON |
POST |
/api/notice |
Put a notice on the panel; needs the token |
DELETE |
/api/notice |
Take the notice on show down early; needs the token |
| Variable | Default | Description |
|---|---|---|
WEB_API_TOKEN |
unset | Bearer token for the notice routes. Unset leaves them shut |
curl -s http://127.0.0.1:8390/api/status{"cpu_load":12,"cpu_temp":48,"memory":31,"disk_usage":67,"disk_temp":41,
"fan_speed":2100,"uptime_minutes":4445,"ip_address":"192.168.1.20","wired":true,
"signal_strength":null,"internet":true,"download_bytes_per_second":1250000,
"upload_bytes_per_second":0,"apt_updates":3,"undervoltage":false,
"nvme_fault":null,"ssd_wear":null,"stale":{"disk_temp":95}}The readings are the ones the panel was last drawn from, taken every
INTERVAL_FAST seconds; asking more often returns the same numbers. Units are
percent, °C, RPM, dBm and bytes per second. null means not known — no such
sensor on this hardware, or no reading yet — never zero standing in for one,
and any reading but wired can be null.
stale names the readings that could not be refreshed on the latest attempt,
each with the age in seconds of the value still reported for it: above, the
disk temperature was last read 95 seconds ago. It is {} when everything is
current. See Readings that cannot be taken.
Names follow the MQTT topics where there is one, except traffic: MQTT
publishes traffic_down and traffic_up in kB/s, the API
download_bytes_per_second and upload_bytes_per_second in bytes per second.
A notice takes the same body as the pipe and MQTT, plain text or JSON (see Notices):
curl -H "Authorization: Bearer $TOKEN" -d 'Backup finished' http://127.0.0.1:8390/api/notice
curl -H "Authorization: Bearer $TOKEN" -d '{"text": "Deploy running", "duration": 300}' http://127.0.0.1:8390/api/notice
curl -H "Authorization: Bearer $TOKEN" -X DELETE http://127.0.0.1:8390/api/notice| Answer | When |
|---|---|
202 |
Accepted; it is on the panel within a moment |
400 |
Nothing visible in the text, or a malformed request |
401 |
Token missing or wrong |
403 |
No WEB_API_TOKEN configured, or NOTIFY_ENABLED=false |
411 |
A chunked body; send Content-Length, as curl -d does |
413 |
Body over 4 KB |
- Reading is as open as the preview.
/api/statusshows nothing the frame does not, so it needs no token, and the loopback default applies to it too. - Writing is shut until you choose a token. Listening on a port is not a
decision to let whoever reaches it write on the panel. Pick a long random
one, e.g.
openssl rand -hex 32, and keep it where only root can read it. The.debships/etc/default/sys-inkas mode 0640, so it can go there; on a manual install, or if you have loosened that file, use a root-only environment file as for the MQTT password. The daemon withholds it from the commands it runs, as it does the MQTT password. - The token travels in clear. There is no TLS. On loopback or through the
SSH tunnel that does not matter; with
WEB_PREVIEW_ADDR=0.0.0.0anyone who can watch the network can read it, so only open the API up on a network you trust, or put a TLS-terminating proxy in front. - A notice sent over HTTP is not queued. Like the other sources, a newer one replaces the one waiting or on show.
To reach the preview and the API from elsewhere with TLS, leave the daemon on
loopback and put a proxy on the same host in front of it. The viewer page
fetches frame.bmp by a relative URL, so it works under a path prefix as well
as at the root. The proxy has to pass Authorization through, which nginx and
Caddy do by default. With nginx:
# In the http block: a notice a second from any one address.
limit_req_zone $binary_remote_addr zone=sysink_notice:1m rate=1r/s;
# The preview and /api/status take no token, so say here who may see them.
location /sysink/ {
allow 192.168.0.0/16;
deny all;
proxy_pass http://127.0.0.1:8390/;
}
# Notices carry a token of their own, so this may reach further than the rest.
location = /sysink/api/notice {
limit_req zone=sysink_notice burst=5 nodelay;
proxy_pass http://127.0.0.1:8390/api/notice;
}The preview and /api/status need no token. Without the allow and deny
they are as open as the proxy is, and tell whoever asks the machine's address
on its own network, how long it has been up and how many updates it is behind;
leave them out only if that is what you mean. The limit on the notice route is
what stops a token being guessed at speed, since the daemon keeps no count of
failed attempts.
Other programs can show a message on the panel. It replaces the dashboard for
NOTIFY_DURATION seconds and then the dashboard comes back, readings current,
with a full refresh to clear the large text's ghost.
On the machine itself, write to the pipe:
echo "Backup finished" > /run/sys-ink/notifyFrom anywhere that reaches the broker, with MQTT enabled, publish to
<MQTT_TOPIC_PREFIX>/notify:
mosquitto_pub -h broker -t sysink/notify -m "Backup finished"With WEB_PREVIEW on and a WEB_API_TOKEN set, POST it to the
HTTP API:
curl -H "Authorization: Bearer $TOKEN" -d "Backup finished" http://127.0.0.1:8390/api/noticeAll three take the same payload: plain text, or JSON when a notice should stay up longer or shorter than the default:
mosquitto_pub -t sysink/notify -m '{"text": "Deploy running", "duration": 300}'message is accepted for text. duration is in seconds, at most a day; it
may be a fraction or a number in a string, and zero, a negative number or
leaving it out means NOTIFY_DURATION. {"text": ""} takes the notice on show
down early. Plain text with nothing visible in it, only spaces or control
characters, is ignored rather than shown as a blank panel.
| Variable | Default | Description |
|---|---|---|
NOTIFY_ENABLED |
true |
Accept notices: create the pipe, subscribe to the MQTT topic when MQTT is enabled, and open the API's notice routes when it has a token |
NOTIFY_FIFO |
/run/sys-ink/notify |
Path of the pipe. Its directory is created if missing; nothing above it is |
NOTIFY_GROUP |
unset | Group allowed to write besides root. Unset leaves the pipe root-only |
NOTIFY_DURATION |
30 |
Seconds a notice stays up |
-
A newer notice replaces the one on show and restarts its timer.
-
Notices change the panel at most once every two seconds. One goes up at once, but the next to arrive within two seconds of that waits out the rest of them, and of several arriving meanwhile only the latest is shown. A sender stuck in a loop therefore costs one refresh and one log line per interval, however fast it writes. The same notice sent again while it is up only restarts its timer: nothing is redrawn, and it is logged at debug level.
-
On the pipe, one line is one notice. The line ends at a newline or at the end of the write, so
printfworks as well asecho. A JSON notice may be spread over several lines, asjqprints it. -
A hardware fault still shows. The inverted status bar for under-voltage or a SMART warning is drawn over a notice too, however long it stays up.
-
Text is set in the largest font that fits, from one large line down to six small ones. Longer text is cut off with
...; about 250 characters is the most the panel holds. -
The fonts cover ASCII only. Diacritics are dropped (
Příliš žluťoučkýshows asPrilis zlutoucky), typographic quotes and dashes become their ASCII lookalikes, and anything else, emoji included, shows as?. The log has a notice the same way, on one line, so its text cannot pass for log lines. -
Who may write is decided by the pipe's permissions:
0600root-only by default,0620withNOTIFY_GROUPset. To let a program running as an ordinary user post notices, put that user in a group and name it:sudo groupadd -f sysink-notify sudo usermod -aG sysink-notify hermes echo 'NOTIFY_GROUP=sysink-notify' | sudo tee -a /etc/default/sys-ink sudo systemctl restart sys-ink
-
A writer blocks while nothing reads. Opening a pipe for writing waits for a reader. Under the packaged unit the directory disappears when the service stops, so the write fails at once instead; when running the daemon by hand the pipe stays behind, and a sender that must never hang should bound it:
timeout 2 sh -c 'echo "..." > /run/sys-ink/notify'. -
Over MQTT, retained notices are ignored. A retained message is replayed to every new subscriber, which would bring an old notice back each time the daemon reconnects. Publish without
-r. An empty message is ignored as well: it is what clearing a retained one (mosquitto_pub -r -n) delivers. Who may publish is up to the broker's ACL; anyone who can write to the topic can put text on the panel. -
Over MQTT, notices arrive only while connected. One sent while the daemon is reconnecting is lost, since the subscription is QoS 0 on a clean session: a notice shown late is worse than one not shown. A broker that vanishes without closing the connection is noticed by TCP keepalive within about a minute and a half, and the daemon reconnects and subscribes again.
With discovery on, the device gains a notify entity, notify.sysink_panel,
that publishes to the notice topic. An automation can then put a notice on the
panel like any other notification:
action: notify.send_message
target:
entity_id: notify.sysink_panel
data:
message: "Washing machine finished"Home Assistant names the entity after the device and its area, so a panel
assigned to an area gets that prefix, e.g. notify.office_sysink_panel; the
actual ID is under Settings → Devices & Services → MQTT → SysInk. Turning
notices off with NOTIFY_ENABLED=false removes the entity again.
examples/hermes/sysink-panel is a skill for
Hermes Agent: when to use the panel
and when not (it is readable by anyone in the room), how short to keep the
text, and a helper script that writes to the pipe or falls back to MQTT.
Install it with:
cp -r examples/hermes/sysink-panel ~/.hermes/skills/An agent whose commands run in a sandbox container cannot see the pipe, so
the helper falls back to MQTT: with a standard-library Python publisher, which
keeps the password out of the process list, or mosquitto_pub where there is
no Python, reaching the host's broker
through the container's gateway. Give it credentials in sysink.env next to
SKILL.md, readable only by you:
cat > ~/.hermes/skills/sysink-panel/sysink.env <<'ENV'
SYSINK_MQTT_USER=hermes
SYSINK_MQTT_PASSWORD=...
ENV
chmod 600 ~/.hermes/skills/sysink-panel/sysink.envSYSINK_MQTT_HOST, SYSINK_MQTT_PORT and SYSINK_MQTT_TOPIC go there too if
the defaults do not fit. A broker user of its own for the agent is better than
sharing Home Assistant's, and a broker ACL can limit it to the notice topic.
The skill follows the agentskills.io format, so other
agents that read SKILL.md skills can use it too; the helper,
scripts/notify.sh, works on its own as well.
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL |
INFO |
DEBUG, INFO, WARN (or WARNING) or ERROR |
LOG_TO_FILE |
false |
Also write log records to a file |
LOG_FILE_PATH |
/var/log/sys-ink.log |
Log file path, appended to. Lines carry a UTC date and time. The .deb rotates the default path weekly through logrotate. If the file cannot be opened, logging continues on stderr |
EXPORT_BMP |
false |
Enable BMP export for web debugging |
BMP_EXPORT_PATH |
/tmp/sys-ink.bmp |
Path for exported BMP |
Boolean variables are true for 1, true, yes or on and false for 0,
false, no or off, all case-insensitive. Anything else still reads as
false, with a warning in the log. Left blank, a boolean is its default, like
any other variable; where that default is on, the log says so, because
earlier releases read a blank as off.
SysInk can publish metrics to an MQTT broker for Home Assistant integration with auto-discovery support.
| Variable | Default | Description |
|---|---|---|
MQTT_ENABLED |
false |
Enable MQTT publishing |
MQTT_HOST |
localhost |
MQTT broker hostname (DNS, mDNS .local and /etc/hosts all work) |
MQTT_PORT |
1883 |
MQTT broker port |
MQTT_USERNAME |
(none) | MQTT username (optional) |
MQTT_PASSWORD |
(none) | MQTT password (optional) |
MQTT_CLIENT_ID |
sysink |
MQTT client identifier, and the device's identity in Home Assistant |
MQTT_TOPIC_PREFIX |
same as MQTT_CLIENT_ID |
Topic prefix for all messages |
MQTT_DISCOVERY |
true |
Enable Home Assistant auto-discovery |
Discovery configs are republished on every successful connection, so the device appears in Home Assistant even when the broker was unreachable at boot.
More than one panel: give each its own MQTT_CLIENT_ID. The discovery topics,
the entities' unique IDs and the device all derive from it, and the topic prefix
follows it unless set, so that one variable is enough. The default sysink
derives exactly what earlier releases published, so a single existing panel
keeps its entities.
Entities go unavailable in Home Assistant after three INTERVAL_FAST periods
without an update, so a panel that is switched off does not keep showing its last
readings as current. The same applies to readings the daemon withholds on
purpose: an IP address that is gone, a disk temperature or fan the hardware does
not have, and any reading it could not refresh on its latest attempt (see
Readings that cannot be taken). Numeric
sensors carry state_class: measurement, so Home Assistant keeps long-term
statistics for them.
The broker's name is looked up off the main loop, which waits a second for the answer and otherwise connects on a later cycle, so a nameserver that has stopped answering delays MQTT and nothing else. A broker given by address is not looked up at all.
Storing the password: avoid Environment=MQTT_PASSWORD=... in a unit file —
it is readable by any local user via systemctl show sys-ink. Prefer a
root-only environment file:
sudo install -m 600 /dev/null /etc/sys-ink/mqtt.env
sudo tee /etc/sys-ink/mqtt.env >/dev/null <<'EOF'
MQTT_ENABLED=true
MQTT_HOST=192.168.1.100
MQTT_USERNAME=homeassistant
MQTT_PASSWORD=secret
EOF
sudo systemctl edit sys-inkAdd:
[Service]
EnvironmentFile=/etc/sys-ink/mqtt.envAfter enabling MQTT, sensors will automatically appear in Home Assistant under
the "SysInk" device — the metrics shown on the panel plus SSD wear, and two
problem entities (Under-voltage, NVMe SMART Fault) that Home Assistant can
turn into notifications. SMART reading needs the daemon to run as root, which
the packaged service does; unprivileged runs disable it silently.
The device goes unavailable in Home Assistant as soon as the daemon stops:
it publishes offline to <MQTT_TOPIC_PREFIX>/status on a clean shutdown, and
the broker publishes the same as its Last Will after a crash or kill -9. A Pi
that loses power sends nothing, so there the entities time out after three
INTERVAL_FAST periods instead.
A ready-made dashboard showing all of it is provided in
examples/home-assistant/dashboard.yaml
— paste it into a new dashboard's raw configuration editor. The file explains
how, and what to check if your entity IDs differ.
- Ensure SPI is enabled (
sudo raspi-config-> Interface Options -> SPI). - Check wiring connections.
- Verify the user has permissions to access
/dev/spidev0.0and/dev/gpiochip*.- Add user to groups:
sudo usermod -a -G spi,gpio <username>
- Add user to groups:
- Run with
LOG_LEVEL=DEBUG— the SPI and GPIO failures name the device path and errno.
- Ensure you have the V2 version of the Waveshare 2.9" display. V1 uses different LUTs and commands.
- Check if the display is correctly seated in the HAT or if wires are loose.
- Persistent ghosting means full refreshes are too rare; lower
INTERVAL_FULL_REFRESH.
- This is a warning from the Zig allocator in debug/release-safe modes. It usually indicates a clean shutdown didn't happen (e.g.,
kill -9). UseCtrl+C,SIGTERMorsystemctl stopfor a graceful shutdown, which also parks the panel in deep sleep.
See CHANGELOG.md. Breaking changes are listed in a table at the
top — most recently, in 1.10.1, Zig 0.17.0 for building from source, and in
1.8.0 the Home Assistant discovery identity following MQTT_CLIENT_ID, which
matters only if you changed it from the default.
MIT — see LICENSE.



