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