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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user