Files
mokhtar e4c34784c8 rewrite as a daemon with shutdown on low battery
the oneshot+timer produced 8367 journal lines a day of systemd
start/stop noise. now Type=notify with an internal ticker, logging
transitions only.

hardware sits behind sensor.Sensor; internal/monitor is pure, so the
shutdown state machine is tested without a pi.

four guards, each tested, because a false poweroff of a box with no
physical access is worse than a missed one:
- a failed read resets the arming window rather than pausing it
- voltage must actually fall across the window, else it is a stuck
  sensor. this is the only defence against the ac line reading 0
  while mains is connected, so it is load-bearing
- a settle period stops a restart loop acting on early readings
- flapping ac cannot accumulate

verified on hardware 2026-08-09: after poweroff on battery the x1208
starts the pi again when mains returns, with no button press. that
was the risk that could have made this feature unsafe.

a failed tick rewrites the previous sample with sensor_healthy 0 and
the failure counter, so last_update still freezes for the staleness
alert while the failure stays visible.

module path moved to git.mial.net. debian units and the compose
overlay deleted; deployment lives in the infra repo.
2026-08-09 02:01:19 +02:00

10 KiB
Raw Permalink Blame History

x1208-exporter — daemon rewrite plan

Oneshot+timer → long-running daemon. Why: the 30 s oneshot generates 8,367 journal lines/day (systemd Starting/Finished noise), ~33 KB SD writeback per sparse journal entry on the old box. Target: Type=notify, Restart=always, internal ticker, near-zero steady-state logging. Greenfield rules apply: breaking changes allowed, no debt carried.

Consumer: rpi.mial.net (NixOS, buildGoModule, aarch64). Metric names are LOAD-BEARING (Grafana alert RpiUpsExporterStale + dashboard): rpi_ups_last_update_seconds, rpi_ups_ac_power, rpi_ups_voltage_volts, rpi_ups_battery_percent — never rename.

Decisions

# Question Call Why
1 Textfile vs HTTP /metrics Keep the textfile. Zero blast radius: alert, dashboard, Alloy config, node-exporter scrape all unchanged (no_data_state=Alerting + time()-last_update verified correct as-is). HTTP would add prometheus/client_golang (heavy dep for 4 gauges + go runtime noise), a listener + port, a new Alloy scrape job in the other repo, and change alert semantics (frozen-file staleness → absent-series) for no functional gain. The atomic write already exists and is correct. Daemon rewrites the file per tick; textfile-as-IPC ambiguity is resolved by decision 4 (no second reader anymore).
2 Resource lifecycle Held open, reopen-on-error. i2c fd + GPIO line acquired at startup, held across ticks. Any read error after the existing retry (3×20 ms): close BOTH handles, next tick reacquires from scratch. Held-open = cheap ticks, no per-tick open/close syscall churn. Reopen-on-error gives per-read robustness where it matters: a wedged i2c bus or vanished gpiocdev line heals on the next tick instead of poisoning the fd forever. Failure to reacquire = failed tick, covered by decision 3; daemon never exits over hardware errors (Restart=always is the backstop for panics, not for a flaky bus).
3 Failure + logging policy Log transitions only. Failed tick = no write. Logs exactly: one startup line, AC state change (info), enter error state (warn, first failed tick only), exit error state (info, with failure count). NOTHING on a successful tick. Metric policy: the file is written only from a COMPLETE successful sample (all three readings); partial or failed tick leaves the previous file untouched, so last_update freezes and the existing staleness alert fires after its designed 3 m + 3 m — no new alert needed, no partial-file states to reason about. Additive: rpi_ups_read_failures_total counter (resets on restart; rate() handles that).
4 Shutdown responsibility In this daemon, flag-gated, default OFF. Supersedes the separate ups-shutdown.nix file-reader in rpi.mial.net (that module shrinks to flags + polkit rule; flagged to the other repo, not edited here). The daemon owns the readings; a second process re-parsing a textfile the first just wrote re-implements sampling, staleness guarding and hysteresis against a lossy serialization of state it could have had directly — that is the brittle architecture. Failure coupling is identical either way (dead exporter = stale file = separate reader must no-op too). Privilege solved without root: daemon execs systemctl poweroff (no D-Bus dep), authorized by a polkit rule matching a static group the unit joins. Trigger: ac==0 AND (voltage < 3.5 OR soc < 15) sustained N consecutive ticks (default N=4 → 2 min), thresholds ABOVE the 3.3 V RpiUpsBatteryCritical alert so the page fires before the poweroff. Staleness guard inherent: only fresh successful samples feed the state machine. One warn log when armed, one when disarmed, one at poweroff.
5 Testability Interface seam + pure cores, below. Threshold/transition/render logic unit-testable on any machine; hardware isolated to two leaf packages.
6 Packaging Flake in this repo exporting the package; rpi.mial.net consumes it as a flake input (nixpkgs follows). Tagged releases vX.Y.Z; ldflags "-X main.version=". vendorHash + version live NEXT to the code — one commit bumps both, Renovate bumps the consumer's flake.lock (nix manager already enabled there). fetchFromGitea + rev/hash pairs in the consumer would smear this repo's build metadata across two repos. Repo goes public (decided), so the build-time-fetch-needs-no-auth constraint is satisfied either way; flake is the cleaner shape. NO nixosModule in this repo: the systemd unit is infra policy and lives in rpi.mial.net/nixos/modules/x1208-exporter.nix — two sources of unit truth is exactly the split-brain to avoid.
7 Repo hygiene Below.
8 Unit shape Below (reference for the infra module).

Architecture

main.go               flags, wiring, signal handling — no logic
internal/max17040/    fuel gauge: open/ioctl/read-word/retry/scale (from current readBattery)
internal/pld/         GPIO 6 AC-present line via gpiocdev (from current readACPower)
internal/sensor/      type Reading { AC bool; Volts, SOC float64; At time.Time }
                      type Sensor interface { Read() (Reading, error); Close() error }
                      real: composes max17040+pld, owns held-open handles + reopen-on-error
                      fake: scripted readings/errors for tests
internal/monitor/     the daemon core, PURE: ticker loop driven by injected clock,
                      transition detection (AC change, error enter/exit),
                      shutdown state machine (N-sample hysteresis) → Action enum,
                      emits Reading → sink; no I/O, no syscalls
internal/textfile/    Render(Reading, now) string  (golden-tested, byte-identical
                      metric names/HELP to today) + WriteAtomic (moved, unchanged)
internal/sdnotify/    READY=1 / WATCHDOG=1 datagram to $NOTIFY_SOCKET (~30 lines, no dep)
  • monitor takes Sensor, clock, and callbacks (write file, log, act); unit tests cover: transition-only logging, failed-tick-no-write, N-sample shutdown hysteresis incl. flapping AC, staleness never triggers shutdown, counter increments.
  • Keep: gpiochip0 by name (no Debian symlink dependency), validation ranges (2.05.0 V, ≤110 %), 3×20 ms retry, big-endian word read, atomic rename.
  • Interval: -interval 30s flag, same cadence as today. -out flag kept. Shutdown: -shutdown, -shutdown-voltage 3.5, -shutdown-soc 15, -shutdown-samples 4, -shutdown-cmd "systemctl poweroff" (injectable for tests).

Systemd unit (reference — authoritative copy goes in rpi.mial.net)

Type=notify            WatchdogSec=90s        # 3 missed ticks → systemd restarts a hung daemon
Restart=always         RestartSec=5s
DynamicUser=yes
SupplementaryGroups=i2c ups                    # i2c: /dev/i2c-1; ups: static group — owns the
                                               # textfile dir (g+w) AND matches the polkit rule
DeviceAllow=/dev/i2c-1 rw
DeviceAllow=/dev/gpiochip0 rw
ReadWritePaths=/var/lib/node-exporter/textfiles
Nice=10  MemoryMax=32M
NoNewPrivileges / ProtectSystem=strict / ProtectHome / PrivateTmp /
ProtectKernel{Tunables,Modules,Logs} / ProtectControlGroups / ProtectClock /
RestrictAddressFamilies=AF_UNIX / RestrictNamespaces / RestrictRealtime /
LockPersonality / MemoryDenyWriteExecute / SystemCallFilter=@system-service /
SystemCallArchitectures=native / UMask=0022

Timer: deleted — no timer exists anymore. Root dropped entirely: device access via groups, poweroff via polkit (org.freedesktop.login1.power-off allowed for unix-group:ups). MemoryDenyWriteExecute is safe for pure Go. /dev/gpiochip0 group access needs a udev rule on NixOS (infra repo's job — flagged, not assumed).

Repo hygiene

Item Action
build/x1208-exporter committed binary Remove. Repo has ONE commit and is about to go public: rewrite history (fresh init or amend) so the binary is not in it forever. Cheap now, impossible later.
.gitignore /build/, /result, /result-*, *.prom
Module path github.com/mokhtar/x1208-exporter Wrong host — repo lives at git.mial.net. Rename to git.mial.net/mokhtar/x1208-exporter. Breaking, allowed, correct.
go.mod Go 1.23 Bump to current stable at rewrite time. Deps: keep go-gpiocdev; DROP golang.org/x/sys from direct use only if max17040 no longer needs it (it does — ioctl; keep).
systemd/ (Debian units) + justfile scp-deploy recipes Delete. NixOS module in rpi.mial.net is the only deployment; dead Debian paths are debt. justfile keeps: build, test, lint, run-fake (daemon against fake sensor, prints file to stdout).
README Rewrite for daemon + flake usage; drop compose/Debian instructions.

Order of work

  1. History rewrite + hygiene (binary out, gitignore, module path). Push before publicizing.
  2. Package split with tests, behavior-preserving (still produces identical .prom bytes — golden test locks this).
  3. Daemon loop: ticker, held-open sensor, transition logging, sdnotify + watchdog.
  4. Shutdown state machine, flag-gated off.
  5. Flake (package + devShell), tag v1.0.0.
  6. Consumption + unit + polkit + udev + ups group land in rpi.mial.net (other repo — out of scope here; includes retiring ups-shutdown.nix as a separate reader).

Unresolved questions

  1. Shutdown thresholds sign-off: 3.5 V / 15 % / N=4 (2 min sustained) — above the 3.3 V page, below nuisance range. Confirm numbers.
  2. Static group name for textfile-dir write + polkit match (ups?) — infra repo decides, this plan assumes one group serves both.
  3. Polkit rule exact shape on NixOS (security.polkit.extraConfig matching org.freedesktop.login1.power-off for the group) — verify action id covers systemctl poweroff non-interactively before relying on it; fallback is a root-owned wrapper with a narrow sudoers line (worse; avoid if polkit works).
  4. Does anything else read the textfile once ups-shutdown-as-reader dies? (Believed: only node-exporter. Confirm before changing write policy to no-partial-updates.)
  5. rpi_ups_read_failures_total: worth a Grafana panel, or leave unalerted as diagnostic? (No alert planned — staleness alert remains the pager.)