Files
x1208-exporter/internal/monitor/monitor.go
T

279 lines
7.9 KiB
Go

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