Two-part monitoring stack that shows Linux host metrics on an Arduino Nano with a 20x4 HD44780 LCD. The Arduino handles the UI (telemetry vs. command list) while a Python daemon polls system sensors, formats the frames, and exchanges commands over serial.
arduino/– PlatformIO project for the Nano sketch (LCD driver, rotary encoder, serial protocol).server/– Python 3.12 daemon with config loader, sensor collectors, command protocol, and systemd entrypoint.infra/systemd/– Example unit files for user and system service deployments.docs/– Wiring, ADRs, and deployment guides (docs/deployment-systemd.mdexpands on the service setup).Makefile– Shared tasks for formatting, linting, testing, builds, and service helper commands.
- Python 3.12 with uv (or
python3 -m venvfallback). - PlatformIO CLI (
pio) and serial access to the Arduino Nano. The Makefile auto-detects the VS Code extension’s bundled binary at~/.platformio/penv/bin/pio; override withmake PIO=/path/to/pio …if installed elsewhere. - Optional GPU telemetry:
pynvmlornvidia-smiavailable on the host.
make setup # sync Python deps with uv
make fmt # format Python code (ruff + black)
make lint # ruff linting
make type # mypy (strict)
make test # pytest
make arduino-buildHardware upload/monitor commands are available via make arduino-upload and make arduino-monitor. Day-to-day development typically uses the PlatformIO VS Code extension’s GUI shortcuts; the Make targets remain available for scripted runs. If the CLI is not on PATH, either rely on the auto-detected ~/.platformio/penv/bin/pio or provide PIO=/path/to/pio when invoking make.
-
Dry run on a development box (no Arduino required):
make server-dry-run
This prints the assembled LCD lines to stdout once and exits.
-
Full daemon using the sample config and the local serial device:
make server-run
The config is driven by
server/config.example.yaml; copy and edit it for your host. Enable command execution explicitly with--allow-execand pick an execution driver (shellis the default,systemd-userandsystemd-systemremain available). For logging,--verboseelevates output to INFO, while--log-level=<LEVEL>(CRITICAL/ERROR/WARNING/INFO/DEBUG) provides explicit control. Each telemetry frame starts with a metadata line (META interval=<seconds>) so the Arduino can scale its watchdog before rendering the remaining lines; the sketch hides the metadata from the LCD.Pass extra CLI flags through the Make targets with
SERVER_ARGS—handy for turning on debug logging or experimenting with other options:make server-run SERVER_ARGS="--log-level=DEBUG" make server-dry-run SERVER_ARGS="--log-level=INFO --once" make server-run SERVER_ARGS="--log-level=INFO --allow-exec --exec-driver systemd-user"
The same variable works with
make server-run-pipandmake server-dry-run-pip.
Systemd units live in infra/systemd/ and are documented in detail in docs/deployment-systemd.md. In short:
-
User service (development) –
infra/systemd/lcdmonitor.serviceruns under the logged-in user. Copy it to~/.config/systemd/user/, adjustWorkingDirectory/ExecStart, then run:systemctl --user daemon-reload systemctl --user enable --now lcdmonitor journalctl --user -u lcdmonitor -fFor headless operation, enable lingering via
loginctl enable-linger $USER.make service-user-installonly prints these instructions—you still copy/edit the unit manually. -
System Service (production) –
infra/systemd/lcdmonitor.system.serviceassumes a dedicated service account (e.g.lcdmon) that belongs to the serial group (dialout,uucp, etc.). Create/etc/lcdmonitor/config.yaml, then create/etc/default/lcdmonitorwith:LCDMONITOR_VENV=/opt/lcdmonitor/.venv LCDMONITOR_CONFIG=/etc/lcdmonitor/config.yaml
Prerequisites before installation:
- Create the service user and add it to the serial group (e.g.,
sudo useradd -r -s /usr/sbin/nologin -G dialout lcdmon). - Decide on an install root (
INSTALL_ROOT, default/opt/lcdmonitor). - Prepare the Python virtualenv at
${INSTALL_ROOT}/.venvwith runtime deps (pip install /opt/lcdmonitor/server) or runmake setuplocally and copy the environment. If the virtualenv isn't ready yet, run the installer withENABLE_SERVICE=0so it won't start the daemon until you finish provisioning.
Automated install (overrides optional):
sudo make service-system-install SERVICE_USER=lcdmon SERVICE_GROUP=dialout INSTALL_ROOT=/opt/lcdmonitor \ CONFIG_PATH=/etc/lcdmonitor/config.yaml ENV_FILE=/etc/default/lcdmonitor
Run this from a clean checkout on the server. The target rsyncs the current repo into
${INSTALL_ROOT}(setCOPY_REPO=0to skip; falls back totarifrsyncis unavailable), seeds/etc/default/lcdmonitor, copies the hardened unit into/etc/systemd/system/lcdmonitor.service, adjusts ownership of/etc/lcdmonitor/config.yaml, reloads systemd, and enables the service (unlessENABLE_SERVICE=0). Command execution stays disabled by default; add--allow-exec --exec-driver shelltoExecStartonce you have a whitelist in the config (or switch to another driver if you have systemd privileges configured).Once the virtualenv is provisioned, future updates only require pulling latest code and running:
sudo make service-system-update SERVICE_USER=lcdmon SERVICE_GROUP=dialout INSTALL_ROOT=/opt/lcdmonitor \ CONFIG_PATH=/etc/lcdmonitor/config.yaml ENV_FILE=/etc/default/lcdmonitor
This reruns the installer with
ENABLE_SERVICE=0, upgrades the installed Python package in the venv, then restarts the service and prints its status.Root-level commands should use
sudo -nand you must grant the service user explicit passwordless sudo rules (e.g.,lcdmon ALL=(root) NOPASSWD:/sbin/shutdown,/sbin/reboot) to avoid prompts. The bundled system unit setsNoNewPrivileges=noso these sudo calls can elevate; review and tighten other hardening knobs as needed for your environment. The installer also prepares/home/lcdmon/.cache/pipand all pip invocations usesudo -H -u lcdmon …so virtualenv upgrades do not warn about unwritable caches. - Create the service user and add it to the serial group (e.g.,
Make helpers print the same instructions for quick reference:
make service-user-install
make service-system-notes
sudo make service-system-install [SERVICE_USER=… SERVICE_GROUP=… INSTALL_ROOT=…]
sudo make service-system-update [SERVICE_USER=… SERVICE_GROUP=… INSTALL_ROOT=…]service-system-install expects the prerequisites above (existing service account, deployed repo, ready virtualenv). It will warn if the user or group are missing.
make ciruns formatting checks, lint, mypy, pytest, and an Arduino build.make e2e PORT=/dev/ttyACM0builds the sketch and runs the mock sender against connected hardware.uvx pip-audit(viamake audit) surfaces Python dependency issues.
- CPU summary: psutil for CPU%/RAM%, CPU package temp from the
coretempchip labelPackage id 0when available, otherwise the first exposed temperature sensor. - GPU summary: NVML (
pynvml) first, falling back tonvidia-smi(util%, memory%, GPU temp). - Generic temps (
provider: tempin config): psutilsensors_temperatures()with optionalchip/labelfilters from the YAML config.
- Wiring diagram and bill of materials:
docs/wiring.md. - Systemd deployment deep dive:
docs/deployment-systemd.md. - Architecture and decisions:
docs/adr/. - Codex collaboration:
AGENTS.md,config.toml,docs/codex-playbook.md,docs/codex-howto.md.
Hardware validation requires the attached Arduino; if Codex cannot run those checks, pending tests are called out in task notes and handoff summaries for manual follow-up.
Keep docs, tests, and configs in sync with behavior changes before opening a PR.