// 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 retries int } // 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 } // ActuationFailed reports that the poweroff command did not succeed. // // It clears the fired latch so a later qualifying tick tries again. Without // this, one failed `systemctl poweroff` — a transient polkit denial, a busy // D-Bus, logind restarting — would silently disarm the feature for the rest of // the discharge, and the battery would reach cell cutoff and hard-cut the // machine. That is precisely the outcome shutdown exists to prevent, so a // failed attempt must not be treated as a completed one. // // The arming state is left intact: the battery is still draining, so the next // qualifying tick retries one interval later rather than restarting the whole // sample window. func (m *Monitor) ActuationFailed() { m.firedShut = false m.retries++ } // Retries reports how many times actuation has failed and been re-armed. func (m *Monitor) Retries() int { return m.retries } 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}} }