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,257 @@
|
||||
// Package monitor holds the daemon's decision logic.
|
||||
//
|
||||
// Everything here is pure: it takes a reading and an error, and returns what
|
||||
// to log, what to write, and whether to act. It performs no I/O and reads no
|
||||
// clock. That is what makes the shutdown path testable without a Pi.
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.mial.net/mokhtar/x1208-exporter/internal/sensor"
|
||||
)
|
||||
|
||||
// Event names a state transition. The daemon logs transitions only: a healthy
|
||||
// tick produces no output at all.
|
||||
type Event string
|
||||
|
||||
const (
|
||||
EventStartup Event = "startup"
|
||||
EventACChanged Event = "ac_changed"
|
||||
EventErrorEnter Event = "error_enter"
|
||||
EventErrorExit Event = "error_exit"
|
||||
EventShutdownArmed Event = "shutdown_armed"
|
||||
EventShutdownDisarmed Event = "shutdown_disarmed"
|
||||
EventShutdownInhibited Event = "shutdown_inhibited"
|
||||
EventShutdownTriggered Event = "shutdown_triggered"
|
||||
)
|
||||
|
||||
// Log is one line the daemon must emit.
|
||||
type Log struct {
|
||||
Event Event
|
||||
Message string
|
||||
}
|
||||
|
||||
// Config controls the shutdown policy. The zero value disables shutdown.
|
||||
type Config struct {
|
||||
// Shutdown enables the poweroff decision. Default off: the daemon is an
|
||||
// exporter first, and actuation is opt-in.
|
||||
Shutdown bool
|
||||
|
||||
// Volts and SOC are the low-battery thresholds. Either one can arm.
|
||||
Volts float64
|
||||
SOC float64
|
||||
|
||||
// Samples is how many consecutive qualifying ticks must pass before the
|
||||
// daemon acts.
|
||||
Samples int
|
||||
|
||||
// MinUptimeTicks suppresses shutdown for the first N ticks after start.
|
||||
// Without this a restart loop could power the machine off using readings
|
||||
// taken before the hardware settled.
|
||||
MinUptimeTicks int
|
||||
|
||||
// RequireDischarge demands that cell voltage actually fall across the
|
||||
// arming window before the daemon acts.
|
||||
//
|
||||
// This is the only check that distinguishes a real discharge from a stuck
|
||||
// sensor. A wedged fuel gauge repeats one plausible low value forever, and
|
||||
// consecutive samples of a persistent fault are not independent evidence.
|
||||
//
|
||||
// It is LOAD-BEARING, not a refinement. It is the sole defence against the
|
||||
// worst remaining failure: the AC line falsely reading 0 while mains is
|
||||
// actually connected. On mains the charger holds cell voltage flat or
|
||||
// rising, so a false "on battery" cannot satisfy this check. Turn it off
|
||||
// and a single stuck GPIO line can power the machine off during normal
|
||||
// operation.
|
||||
RequireDischarge bool
|
||||
}
|
||||
|
||||
// Result is the outcome of one tick.
|
||||
type Result struct {
|
||||
Logs []Log
|
||||
|
||||
// Sample is the most recent COMPLETE reading, which is not necessarily
|
||||
// this tick's. HaveSample is false until the first successful read.
|
||||
Sample sensor.Reading
|
||||
HaveSample bool
|
||||
|
||||
// Healthy reports whether this tick's read succeeded.
|
||||
Healthy bool
|
||||
Failures uint64
|
||||
|
||||
// Shutdown is true on the single tick where the daemon decides to power
|
||||
// the machine off.
|
||||
Shutdown bool
|
||||
}
|
||||
|
||||
// Monitor tracks state across ticks.
|
||||
type Monitor struct {
|
||||
cfg Config
|
||||
|
||||
started bool
|
||||
ticks int
|
||||
|
||||
lastAC bool
|
||||
haveAC bool
|
||||
|
||||
inError bool
|
||||
errStreak int
|
||||
failures uint64
|
||||
last sensor.Reading
|
||||
haveLast bool
|
||||
armed bool
|
||||
armCount int
|
||||
armVolts float64
|
||||
inhibited bool
|
||||
firedShut bool
|
||||
}
|
||||
|
||||
// New returns a Monitor with the given policy.
|
||||
func New(cfg Config) *Monitor { return &Monitor{cfg: cfg} }
|
||||
|
||||
// Tick advances the state machine by one reading.
|
||||
//
|
||||
// Pass the error from the sensor as err; reading is ignored when err is
|
||||
// non-nil. Tick never blocks and never panics.
|
||||
func (m *Monitor) Tick(reading sensor.Reading, err error) Result {
|
||||
m.ticks++
|
||||
res := Result{Failures: m.failures}
|
||||
|
||||
if !m.started {
|
||||
m.started = true
|
||||
res.Logs = append(res.Logs, Log{EventStartup, m.startupMessage()})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return m.tickFailed(res, err)
|
||||
}
|
||||
return m.tickSucceeded(res, reading)
|
||||
}
|
||||
|
||||
func (m *Monitor) startupMessage() string {
|
||||
if !m.cfg.Shutdown {
|
||||
return "x1208-exporter started; shutdown-on-battery disabled"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"x1208-exporter started; shutdown-on-battery ENABLED below %.2f V or %.0f%% for %d ticks (require-discharge=%t)",
|
||||
m.cfg.Volts, m.cfg.SOC, m.cfg.Samples, m.cfg.RequireDischarge)
|
||||
}
|
||||
|
||||
func (m *Monitor) tickFailed(res Result, err error) Result {
|
||||
m.failures++
|
||||
m.errStreak++
|
||||
res.Failures = m.failures
|
||||
res.Healthy = false
|
||||
res.Sample = m.last
|
||||
res.HaveSample = m.haveLast
|
||||
|
||||
if !m.inError {
|
||||
m.inError = true
|
||||
res.Logs = append(res.Logs, Log{EventErrorEnter, "UPS read failed: " + err.Error()})
|
||||
}
|
||||
|
||||
// A failed tick RESETS the arming window rather than pausing it.
|
||||
//
|
||||
// Pausing would let "low, low, thirty minutes of errors, low, low" reach
|
||||
// the sample count. That is not a sustained low battery and must not power
|
||||
// the machine off.
|
||||
res.Logs = append(res.Logs, m.disarm("sensor read failed")...)
|
||||
return res
|
||||
}
|
||||
|
||||
func (m *Monitor) tickSucceeded(res Result, r sensor.Reading) Result {
|
||||
if m.inError {
|
||||
res.Logs = append(res.Logs, Log{EventErrorExit,
|
||||
fmt.Sprintf("UPS read recovered after %d failed ticks", m.errStreak)})
|
||||
m.inError = false
|
||||
m.errStreak = 0
|
||||
}
|
||||
|
||||
if m.haveAC && m.lastAC != r.ACPresent {
|
||||
res.Logs = append(res.Logs, Log{EventACChanged, acMessage(r.ACPresent)})
|
||||
}
|
||||
m.lastAC = r.ACPresent
|
||||
m.haveAC = true
|
||||
|
||||
m.last = r
|
||||
m.haveLast = true
|
||||
|
||||
res.Healthy = true
|
||||
res.Sample = r
|
||||
res.HaveSample = true
|
||||
|
||||
logs, shutdown := m.evaluateShutdown(r)
|
||||
res.Logs = append(res.Logs, logs...)
|
||||
res.Shutdown = shutdown
|
||||
return res
|
||||
}
|
||||
|
||||
func acMessage(present bool) string {
|
||||
if present {
|
||||
return "AC power restored"
|
||||
}
|
||||
return "AC power lost; running on battery"
|
||||
}
|
||||
|
||||
func (m *Monitor) evaluateShutdown(r sensor.Reading) ([]Log, bool) {
|
||||
if !m.cfg.Shutdown || m.firedShut {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
qualifies := !r.ACPresent && (r.Volts < m.cfg.Volts || r.SOC < m.cfg.SOC)
|
||||
if !qualifies {
|
||||
return m.disarm("battery recovered or AC returned"), false
|
||||
}
|
||||
|
||||
if m.ticks <= m.cfg.MinUptimeTicks {
|
||||
if m.inhibited {
|
||||
return nil, false
|
||||
}
|
||||
m.inhibited = true
|
||||
return []Log{{EventShutdownInhibited, fmt.Sprintf(
|
||||
"low battery within the first %d ticks after start; shutdown suppressed",
|
||||
m.cfg.MinUptimeTicks)}}, false
|
||||
}
|
||||
|
||||
if !m.armed {
|
||||
m.armed = true
|
||||
m.armCount = 1
|
||||
m.armVolts = r.Volts
|
||||
return []Log{{EventShutdownArmed, fmt.Sprintf(
|
||||
"low battery on cells (%.3f V, %.1f%%); shutdown in %d more qualifying ticks",
|
||||
r.Volts, r.SOC, m.cfg.Samples-1)}}, false
|
||||
}
|
||||
|
||||
m.armCount++
|
||||
if m.armCount < m.cfg.Samples {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if m.cfg.RequireDischarge && r.Volts >= m.armVolts {
|
||||
if m.inhibited {
|
||||
return nil, false
|
||||
}
|
||||
m.inhibited = true
|
||||
return []Log{{EventShutdownInhibited, fmt.Sprintf(
|
||||
"battery low but voltage did not fall over %d ticks (%.3f V then %.3f V); "+
|
||||
"treating as a stuck sensor, not a discharge",
|
||||
m.armCount, m.armVolts, r.Volts)}}, false
|
||||
}
|
||||
|
||||
m.firedShut = true
|
||||
return []Log{{EventShutdownTriggered, fmt.Sprintf(
|
||||
"powering off: on battery at %.3f V / %.1f%% for %d consecutive ticks",
|
||||
r.Volts, r.SOC, m.armCount)}}, true
|
||||
}
|
||||
|
||||
func (m *Monitor) disarm(reason string) []Log {
|
||||
m.inhibited = false
|
||||
if !m.armed {
|
||||
m.armCount = 0
|
||||
return nil
|
||||
}
|
||||
m.armed = false
|
||||
m.armCount = 0
|
||||
return []Log{{EventShutdownDisarmed, "shutdown disarmed: " + reason}}
|
||||
}
|
||||
Reference in New Issue
Block a user