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.
86 lines
3.0 KiB
Go
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)
|
|
}
|