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.
5.4 KiB
x1208-exporter
A Prometheus exporter for the Geekworm X1208 UPS HAT on a Raspberry Pi 5.
It reads cell voltage and state of charge from the MAX17040 fuel gauge over i2c, reads the power-loss-detect line over GPIO, and writes a Prometheus textfile that node-exporter's textfile collector scrapes.
Metrics
| Metric | Type | Meaning |
|---|---|---|
rpi_ups_last_update_seconds |
gauge | Unix time of the last COMPLETE sample |
rpi_ups_ac_power |
gauge | 1 on mains, 0 on cells |
rpi_ups_voltage_volts |
gauge | Cell voltage |
rpi_ups_battery_percent |
gauge | State of charge |
rpi_ups_sensor_healthy |
gauge | 1 if the most recent read succeeded |
rpi_ups_read_failures_total |
counter | Failed reads since start |
The first four names are load-bearing. Grafana alerts and the Node Exporter Full dashboard reference them. A golden test locks their exact rendering, including the rounding mode. Do not rename them and do not change their formatting.
Failure behaviour
A read either produces a complete sample or fails. There is no partial sample.
When a read fails the exporter rewrites the file with the previous complete
sample, sets rpi_ups_sensor_healthy to 0, and increments the failure counter.
rpi_ups_last_update_seconds therefore stops advancing, which is what the
RpiUpsExporterStale alert measures. The failure stays visible in the
meantime rather than hiding behind a frozen file.
The exporter logs transitions only: startup, AC changes, entering and leaving an error state, and shutdown decisions. A healthy sample logs nothing. This matters because the machine keeps its journal in RAM and archives it to an SD card.
Shutdown on low battery
The exporter can power the machine off when the cells run down. It is off by
default and must be enabled with -shutdown.
It acts only when all of these hold:
- The AC line reports no mains power.
- Cell voltage is below
-shutdown-voltage, or state of charge is below-shutdown-soc. - That has held for
-shutdown-samplesconsecutive ticks. - More than
-shutdown-settle-ticksticks have passed since start. - Cell voltage actually fell across the window, unless
-shutdown-require-discharge=false.
Each guard exists because a false poweroff is worse than a missed one. The machine it runs on has no physical access, so a wrong decision that halts it cannot be undone remotely.
-
A failed read resets the window. It does not pause it. Otherwise "low, low, a long outage, low, low" would reach the sample count without the battery ever being low for that period.
-
The discharge requirement is load-bearing. Do not turn it off.
It separates a real discharge from a stuck sensor: a wedged fuel gauge repeats one plausible low value forever, and four samples of one persistent fault are not four independent confirmations.
It is also the only defence against the worst remaining failure, an AC line that reads 0 while mains is actually connected. A floating GPIO line reports "on battery" and returns no error. On mains the charger holds cell voltage flat or rising, so a false reading cannot satisfy this check.
TestFalseACLossWhileChargingNeverTriggersShutdowncovers it, andTestWithoutDischargeGuardAFalseACReadingIsEnoughrecords what happens if someone disables the guard. -
The settle period stops a restart loop from acting on readings taken before the hardware settled.
The HAT does restore power after a software shutdown
This was the one question that could have made the whole feature unsafe. The X1208 cuts power to the Pi after halt. If mains returned while the board still held charge, and the board did not then start the Pi, a clean shutdown would strand a machine nobody can reach.
Tested on 2026-08-09 against the real hardware:
- On mains,
rpi_ups_ac_powerread 1 at 4.149 V. - Mains disconnected at the wall.
rpi_ups_ac_powerread 0 at 4.109 V. sudo systemctl poweroff.- Mains reconnected. No button was pressed.
- The Pi booted by itself.
The same run measured the discharge rate under normal load: 40 mV per minute. Across a four-tick window at 30 s that is about 80 mV, against a gauge resolution of 1.25 mV. The discharge guard therefore has a wide margin and cannot block a real discharge.
Still untested: a poweroff while mains stayed connected the whole time.
The test above disconnected mains first. If the board treats "input power
never dropped" differently, that path is unproven. It only matters if a false
AC reading gets past the discharge guard, which is why that guard is
load-bearing. Test it during the soak: run systemctl poweroff with mains
connected and record whether the Pi returns.
Development
just test # unit tests; no hardware needed
just lint
just run-fake # daemon against a scripted discharging battery
just show
just nix-build # build the package as the Pi will
The hardware sits behind sensor.Sensor. internal/monitor holds every
decision and is pure, so the shutdown state machine is tested without a Pi.
internal/max17040 and internal/pld are the only packages that touch
devices.
Deployment
This repository ships the package only. The systemd unit, device access, groups, polkit rules and the decision to enable shutdown are host policy and live in the infrastructure repository. Keeping a unit here as well would create two sources of truth.
Consume it as a flake input and let the infrastructure repository define the service.