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

86 lines
3.0 KiB
Go

// 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)
}