Lightweight Linux host monitoring over MQTT: any number of machines (servers,
NAS boxes, hypervisors, Raspberry Pis — anything with /sys/class/hwmon)
publish CPU/RAM/temperature/disk metrics, which flow into Home Assistant
(auto-discovery) and optionally onto a dedicated ESP32 HUD-style display
— which keeps working whether or not Home Assistant is up.
sysmon2mqtt.py— a single Python file (stdlib +psutil+paho-mqtt), run via systemd on every monitored host. Collects CPU/RAM/ load/uptime, temperatures from/sys/class/hwmon(CPU, motherboard, disks), and filesystem usage; publishes a flat JSON payload to MQTT and generates Home Assistant MQTT discovery configs. Works with paho-mqtt 1.x and 2.x.- ESP32-S3 + ST7789 dashboard (
esphome/, optional) — HUD-style UI with glowing corner accents, dynamic host discovery (wildcard subscription tosysmon/+/state, no YAML changes needed when you add another machine), a CPU history sparkline, per-disk temperatures, an animated boot/connecting screen, backlight dimming, and a button for manual page switching. The display reads MQTT directly — it doesn't depend on Home Assistant. - 14 built-in color themes (
esphome/theme.yaml) — from a muted Nord to a neon Synthwave to popular editor palettes (Tokyo Night, Catppuccin, Gruvbox, One Dark) — plus a two-layer color model: every metric (CPU/RAM/ disk/temperature/WiFi/uptime) has its own identity color, and crossing a warning/critical threshold always overrides to a universal yellow/red.
┌──────────────┐ hwmon/psutil ┌──────────────┐
│ Linux host #1│───────────────▶│ sysmon2mqtt │──┐
└──────────────┘ │ (systemd) │ │ MQTT publish
┌──────────────┐ └──────────────┘ │ every N seconds
│ Linux host #2│───────────────▶ (another copy) ──┤
└──────────────┘ ... ▼
┌──────────────┐
│ MQTT broker │
└──┬────────┬──┘
HA MQTT discovery│ │wildcard subscribe
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Home │ │ ESP32-S3 + │
│ Assistant │ │ ST7789 HUD │
│ (optional) │ │ (optional) │
└──────────────┘ └──────────────┘
The daemon runs on the physical host (not in an LXC/VM), because
containers don't have access to /sys/class/hwmon.
| Role | Requirement |
|---|---|
| Host(s) | any Linux with systemd and /sys/class/hwmon (e.g. Debian, Ubuntu, Proxmox VE, OpenMediaVault) |
| MQTT broker | any (e.g. Mosquitto), reachable from the hosts and (optionally) the ESP32 |
| Display | optional: ESP32-S3 + ST7789 3.2" 240×320, 4-wire SPI |
The display isn't required — the daemon works standalone with any MQTT client or Home Assistant.
apt install -y python3-psutil python3-paho-mqtt lm-sensors
modprobe drivetemp && echo drivetemp >> /etc/modules
sensors-detect --auto # saves modules to /etc/modules-load.d/
sensors # sanity check: CPU/disk temps should show up
mkdir -p /opt/sysmon2mqtt
cp host/sysmon2mqtt.py /opt/sysmon2mqtt/
cp host/sysmon2mqtt.service /etc/systemd/system/
# edit Environment= in the unit: broker, auth, SYSMON_MOUNTS
systemctl daemon-reload && systemctl enable --now sysmon2mqtt
journalctl -u sysmon2mqtt -f # should show "HA discovery: published N entities"Repeat on every host you want to monitor — new hosts show up on the dashboard automatically, with no ESPHome config changes.
End-to-end check (from any machine): mosquitto_sub -h <broker> -t 'sysmon/#' -v
The daemon is configured entirely through Environment= in the systemd
unit — no config files:
| Variable | Default | Description |
|---|---|---|
SYSMON_MQTT_HOST |
192.168.1.10 |
broker address |
SYSMON_MQTT_PORT |
1883 |
port |
SYSMON_MQTT_USER / SYSMON_MQTT_PASS |
empty | auth (empty = anonymous) |
SYSMON_INTERVAL |
15 |
seconds between publishes |
SYSMON_HOSTNAME |
socket.gethostname() |
override the name used in topics |
SYSMON_HA_PREFIX |
homeassistant |
discovery prefix |
SYSMON_MOUNTS |
/ |
comma-separated mount points, e.g. /,/srv/data |
DynamicUser=yes + ProtectSystem=strict in the unit — the daemon only
reads sysfs/procfs and writes nothing, so the full systemd sandbox works
with no extra privileges.
| Topic | Retained | Payload | Published by |
|---|---|---|---|
sysmon/<hostname>/state |
no | JSON (see below) | daemon, every SYSMON_INTERVAL s |
sysmon/<hostname>/status |
yes | online / offline |
daemon; offline is also the LWT |
homeassistant/sensor/sysmon_<hostname>/<key>/config |
yes | JSON discovery | daemon, once at startup |
| Key | Type | Unit | Source |
|---|---|---|---|
cpu_percent |
float | % | psutil.cpu_percent() |
load_1, load_5, load_15 |
float | — | os.getloadavg() |
ram_percent |
float | % | psutil.virtual_memory() |
ram_used_gb, ram_total_gb |
float | GB | same |
uptime_days |
float | days | psutil.boot_time() |
cpu_temp |
float|null | °C | heuristic, see below |
disk_<mount>_percent |
float | % | psutil.disk_usage() for each of SYSMON_MOUNTS |
disk_<mount>_free_gb |
float | GB | same |
temp_<chip>[_<model>_<sdX>]_<label> |
float | °C | hwmon, see below |
Example:
{
"cpu_percent": 12.5, "load_1": 0.42, "load_5": 0.38, "load_15": 0.31,
"ram_percent": 47.0, "ram_used_gb": 3.61, "ram_total_gb": 7.68,
"uptime_days": 12.4, "cpu_temp": 54.3,
"disk_root_percent": 31.0, "disk_root_free_gb": 18.2,
"temp_k10temp_tctl": 54.3,
"temp_drivetemp_wdc_wd20efrx_sda_temp1": 38.0,
"temp_drivetemp_wdc_wd20efrx_sdb_temp1": 39.0
}Evolution rules: keys can be added; renaming or removing an existing key
is a breaking change (it leaves dead entities in HA discovery, and ESPHome
stops parsing it). Hardware-derived keys (temp_*, disk_*) are inherently
dynamic.
Home Assistant discovery is published once, after the first metrics
collection (retain=true). Each metric is a separate sensor entity with
value_template: {{ value_json.<key> }}, a shared availability_topic, and
a shared device (identifiers: [sysmon_<hostname>]). Unit mapping:
*_percent→%, *_gb→GB (device_class: data_size), cpu_temp/temp_*→°C
(device_class: temperature), everything state_class: measurement.
hwmon (read_hwmon_temps): iterates /sys/class/hwmon/hwmon*, reads the
chip name from <hwmon>/name (e.g. k10temp, coretemp, nct6775,
drivetemp, nvme). For drivetemp/nvme, it appends the disk model
(device/model) and block device name (sda, nvme0n1) to the key, to
tell apart multiple identical disks in an array. Readings ≤0 °C and >150 °C
are filtered out (garbage that some Super I/O chips return). The code
doesn't rely on hwmonN numbers (they shift across kernel updates),
only on the chip's name.
cpu_temp heuristic: first match from a list of prefixes —
k10temp_tctl, k10temp_tdie, coretemp_package, coretemp_temp1. Note:
Tctl on some AMD CPUs is offset by a fixed amount from the real
temperature (Tdie) — if it looks suspiciously high or low, check both keys
in the raw payload.
Lifecycle: psutil.cpu_percent(interval=None) is called once at startup
(calibration; subsequent calls return the average since the previous one —
interval=1 is intentionally not used, since it would block the loop).
SIGTERM/SIGINT trigger a clean shutdown (publish offline, loop_stop,
disconnect). Discovery is published only after the first metrics
collection, so the full set of dynamic keys is known.
ili9xxxplatform,ST7789Vmodel, native 240×320,rotation: 90for landscape 320×240.- Data: wildcard subscription to
sysmon/+/state(registered inon_boot, callbacklabrax::store_payloadinesphome/display_draw.h). Hosts are discovered dynamically — name taken from the topic, payload parsed with ArduinoJson into thelabrax::hosts()registry. The HA API is deliberately not used — the display should keep working even when HA is down. - The entire drawing layer (HUD styling, color thresholds, CPU history
sparkline, per-host pages) lives in
esphome/display_draw.h; the YAML only holds fonts, the page schedule, and the call tolabrax::draw_page. - Color themes: the palette lives in
esphome/theme.yaml(included viapackages:) — 15th_*substitutions injected intolabrax::set_theme()inon_boot: 9 UI colors (background/text/frames/thresholds) + 6 per-metric identity colors (th_cpu/th_ram/th_disk/th_temp/th_wifi/th_time— the color of the icon and of the bar/sparkline fill while the metric is OK; crossing a warn/crit threshold still overrides to the universal warning/ critical color). Changing the theme means swapping the hex values in the file (ready-made palettes in the comments: HUD, Amber CRT, Green Phosphor, Synthwave, Dracula, Nord, Solarized Dark, Ice, Tokyo Night, Catppuccin Mocha, One Dark, Obsidian, Gruvbox Dark, Cyberpunk) and reflashing. - Backlight and button: PWM (
ledc2 kHz) +light.monochromatic; full brightness forawake_secs(a substitution), then dims todim_brightness. On a dimmed screen the button just wakes it (current page stays); on an awake screen it advances the page and pauses auto-rotation for 30 s; a double-click on an awake screen toggles auto-rotation on/off (state doesn't survive a reboot). Page rotation: the main screen shows for 24 s, the other pages (one combined details+disks page per host, plus a status page) for 6 s each; the number of pages is dynamic, based on how many hosts have been discovered. A host's combined page shows a compact LOAD/UPTIME/RAM/DYSK summary strip plus a list of every reported disk's temperature below it when it has at least one disk sensor; with none, it falls back to a fuller 2×2 tile layout with more detail (5m/15m load average, RAM in GB, free disk space). - Default color thresholds: CPU temp >75 °C red, >60 °C yellow; disks
50/45 °C; CPU/RAM/disk % >85/70. Host data older than 60 s shows "NO DATA".
esphome/labrax-display.yaml is written for one specific, cheaply available
combo: an ESP32-S3 "Super Mini" (PCB marking HW-747) + a ST7789V
3.2" 240×320, 4-wire SPI panel (e.g. a GoldenMorning T320H8-C40-11
module). The ESP32-S3 has a GPIO matrix, so SPI works on any free pins — with
a different board or display, just swap the GPIO numbers in the YAML.
Hardware note: "HW-747" boards are often listed as 16 MB flash, but are
physically 4 MB (S3FH4R2 variant, 2 MB PSRAM in quad mode, not
octal). flash_size: 16MB in the config causes a bootloop (Failed to verify partition table) — hence flash_size: 4MB in the YAML.
| Display pin | Signal | ESP32-S3 GPIO |
|---|---|---|
| GND | ground | GND |
| VCC | power (3–5V) | 3V3 |
| SCL | SPI clock | GP4 |
| SDA | SPI MOSI | GP5 |
| RES | reset | GP8 |
| DC | command/data | GP7 |
| CS | chip select | GP6 |
| BL | backlight | GP9 (PWM) |
Optional page button: a momentary switch between GP10 and GND (internal pull-up in the config, no extra components needed) — everything works without it, the screen just dims after the timeout and pages can't be switched manually.
Pins GP3/GP45/GP46 (strapping — boot mode selection) and GP19/GP20
(native USB D+/D−) are deliberately left out of the wiring.
pipx install esphome # install the CLI
cp esphome/secrets.yaml.example esphome/secrets.yaml # fill in wifi_*, mqtt_*
esphome run esphome/labrax-display.yamlBoards with native USB (rather than a UART bridge, like most ESP32-S3s)
sometimes need the bootloader forced manually on the first flash: hold
BOOT, press and release RST (while still holding BOOT), release BOOT
after ~1–2 s. If the port isn't auto-detected, check ls /dev/ttyACM*
(ESP32-S3 with native USB shows up as ttyACM*, not ttyUSB*).
After the first boot, worth checking: colors (invert_colors in the YAML —
flip it if they look inverted), orientation (rotation: 90), and whether
the display is actually receiving data (sysmon2mqtt must be running and
publishing to sysmon/<hostname>/state — until there's at least one host,
the screen shows a waiting message, which is expected).
AttributeError: module 'paho.mqtt.client' has no attribute 'CallbackAPIVersion'— older distros ship paho-mqtt 1.x in their repos (noCallbackAPIVersion, added in 2.0), newer ones ship 2.x (where that parameter is required).sysmon2mqtt.pydetects the version viahasattr()and handles both automatically — this shouldn't need any manual fix, but if you still see this error, check you're actually running the current version of the file.- No motherboard temperatures — try
modprobe nct6775; if the Super I/O chip is an ITE part,modprobe it87(sometimes needsacpi_enforce_resources=laxon the kernel cmdline — risk of ACPI conflicts, checkdmesgfirst). drivetempdoesn't show any disks behind a SATA controller (common with M.2-to-SATA cards) — the controller isn't passing through SCT/SMART commands. Diagnose withsmartctl -x /dev/sda | grep -i sct. Asmartctl --jsonfallback is on the roadmap; there's no built-in workaround yet.hwmonNnumbering changes across kernel updates — the code deliberately doesn't rely on the numbers, only on thenamefield in/sys/class/hwmon/hwmonN/name, so this shouldn't require any configuration changes.
homeserver-monitor/
├── host/
│ ├── sysmon2mqtt.py # the daemon (a single file, deliberately)
│ └── sysmon2mqtt.service # systemd unit
└── esphome/
├── labrax-display.yaml # ESP32-S3 + ST7789 config
├── display_draw.h # dashboard drawing layer
├── theme.yaml # 14 built-in color themes
└── secrets.yaml.example # secrets template (WiFi/MQTT)
Daemon and display tested end-to-end on real hardware, under active
development. On the roadmap: a smartctl fallback for disks behind
controllers without SCT/SMART passthrough, pytest tests with hwmon
fixtures, and case fan control through the same ESP32.
MIT — do what you want with it, just keep the copyright notice.