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.
This commit is contained in:
2026-08-09 02:01:19 +02:00
parent aae6cee886
commit e4c34784c8
23 changed files with 1774 additions and 321 deletions
+85
View File
@@ -0,0 +1,85 @@
// Package textfile renders and writes the Prometheus textfile that
// node-exporter's textfile collector scrapes.
package textfile
import (
"fmt"
"os"
"path/filepath"
"strings"
"git.mial.net/mokhtar/x1208-exporter/internal/sensor"
)
// Render returns the exposition text for the last complete sample.
//
// The first four metrics are byte-identical to the pre-daemon exporter. Their
// names and value formatting are referenced by Grafana alerts and by the
// dashboard, so they must not change. The golden test locks this.
//
// healthy reports the outcome of the most recent read attempt; failures counts
// failed attempts since process start. Both are additive and safe to scrape.
//
// When a read fails the caller re-renders the PREVIOUS sample with healthy=0.
// rpi_ups_last_update_seconds therefore stops advancing, which is what the
// RpiUpsExporterStale alert measures, while the failure stays visible instead
// of being hidden behind a frozen file.
func Render(s sensor.Reading, healthy bool, failures uint64) string {
ac := 0
if s.ACPresent {
ac = 1
}
health := 0
if healthy {
health = 1
}
var b strings.Builder
b.WriteString("# HELP rpi_ups_last_update_seconds Unix timestamp of last successful exporter run.\n")
b.WriteString("# TYPE rpi_ups_last_update_seconds gauge\n")
fmt.Fprintf(&b, "rpi_ups_last_update_seconds %d\n", s.At.Unix())
b.WriteString("# HELP rpi_ups_ac_power AC adapter present (1=plugged in, 0=on battery).\n")
b.WriteString("# TYPE rpi_ups_ac_power gauge\n")
fmt.Fprintf(&b, "rpi_ups_ac_power %d\n", ac)
b.WriteString("# HELP rpi_ups_voltage_volts Battery cell voltage from MAX17040 fuel gauge.\n")
b.WriteString("# TYPE rpi_ups_voltage_volts gauge\n")
fmt.Fprintf(&b, "rpi_ups_voltage_volts %.3f\n", s.Volts)
b.WriteString("# HELP rpi_ups_battery_percent Battery state of charge from MAX17040 fuel gauge.\n")
b.WriteString("# TYPE rpi_ups_battery_percent gauge\n")
fmt.Fprintf(&b, "rpi_ups_battery_percent %.2f\n", s.SOC)
b.WriteString("# HELP rpi_ups_sensor_healthy Whether the most recent UPS read succeeded (1=yes, 0=no).\n")
b.WriteString("# TYPE rpi_ups_sensor_healthy gauge\n")
fmt.Fprintf(&b, "rpi_ups_sensor_healthy %d\n", health)
b.WriteString("# HELP rpi_ups_read_failures_total Cumulative failed UPS reads since exporter start.\n")
b.WriteString("# TYPE rpi_ups_read_failures_total counter\n")
fmt.Fprintf(&b, "rpi_ups_read_failures_total %d\n", failures)
return b.String()
}
// WriteAtomic writes content to path via a temporary file and a rename, so a
// scrape never observes a half-written file.
func WriteAtomic(path, content string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".x1208.*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.WriteString(content); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(0o644); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
+125
View File
@@ -0,0 +1,125 @@
package textfile
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.mial.net/mokhtar/x1208-exporter/internal/sensor"
)
func sample() sensor.Reading {
return sensor.Reading{
ACPresent: true,
Volts: 4.0625,
SOC: 97.65625,
At: time.Unix(1754661600, 0),
}
}
// The four original metrics must render exactly as the oneshot exporter did.
// Grafana alerts and the dashboard key on these names and this formatting.
//
// The sample values are exact binary fractions that land on a rounding tie, so
// this also locks the rounding mode: Go rounds half to even, which turns
// 4.0625 into 4.062 and 97.65625 into 97.66.
const goldenHealthy = `# HELP rpi_ups_last_update_seconds Unix timestamp of last successful exporter run.
# TYPE rpi_ups_last_update_seconds gauge
rpi_ups_last_update_seconds 1754661600
# HELP rpi_ups_ac_power AC adapter present (1=plugged in, 0=on battery).
# TYPE rpi_ups_ac_power gauge
rpi_ups_ac_power 1
# HELP rpi_ups_voltage_volts Battery cell voltage from MAX17040 fuel gauge.
# TYPE rpi_ups_voltage_volts gauge
rpi_ups_voltage_volts 4.062
# HELP rpi_ups_battery_percent Battery state of charge from MAX17040 fuel gauge.
# TYPE rpi_ups_battery_percent gauge
rpi_ups_battery_percent 97.66
# HELP rpi_ups_sensor_healthy Whether the most recent UPS read succeeded (1=yes, 0=no).
# TYPE rpi_ups_sensor_healthy gauge
rpi_ups_sensor_healthy 1
# HELP rpi_ups_read_failures_total Cumulative failed UPS reads since exporter start.
# TYPE rpi_ups_read_failures_total counter
rpi_ups_read_failures_total 0
`
func TestRenderGolden(t *testing.T) {
got := Render(sample(), true, 0)
if got != goldenHealthy {
t.Errorf("render mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, goldenHealthy)
}
}
// A failed read must not advance the timestamp. That frozen value is exactly
// what RpiUpsExporterStale measures.
func TestRenderUnhealthyFreezesTimestamp(t *testing.T) {
last := sample()
got := Render(last, false, 3)
if !strings.Contains(got, "rpi_ups_last_update_seconds 1754661600\n") {
t.Error("timestamp must stay at the last complete sample")
}
if !strings.Contains(got, "rpi_ups_sensor_healthy 0\n") {
t.Error("unhealthy sample must report rpi_ups_sensor_healthy 0")
}
if !strings.Contains(got, "rpi_ups_read_failures_total 3\n") {
t.Error("failure count must be visible while the sensor is failing")
}
}
func TestRenderACAbsent(t *testing.T) {
s := sample()
s.ACPresent = false
if !strings.Contains(Render(s, true, 0), "rpi_ups_ac_power 0\n") {
t.Error("AC absent must render rpi_ups_ac_power 0")
}
}
func TestWriteAtomicReplacesContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sub", "x1208.prom")
if err := WriteAtomic(path, "first\n"); err != nil {
t.Fatalf("first write: %v", err)
}
if err := WriteAtomic(path, "second\n"); err != nil {
t.Fatalf("second write: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(got) != "second\n" {
t.Errorf("content = %q, want %q", got, "second\n")
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if info.Mode().Perm() != 0o644 {
t.Errorf("mode = %v, want 0644 (node-exporter must be able to read it)", info.Mode().Perm())
}
}
// A rename-based write must never leave temporary files behind for the
// collector to trip over.
func TestWriteAtomicLeavesNoTempFiles(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "x1208.prom")
for i := 0; i < 5; i++ {
if err := WriteAtomic(path, "x\n"); err != nil {
t.Fatalf("write %d: %v", i, err)
}
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Errorf("directory holds %d entries, want 1", len(entries))
}
}