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,88 @@
|
||||
package sensor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Step is one scripted outcome for a Fake.
|
||||
type Step struct {
|
||||
Reading Reading
|
||||
Err error
|
||||
// Block holds the Read for this long before returning, so callers can
|
||||
// exercise their read deadline.
|
||||
Block time.Duration
|
||||
}
|
||||
|
||||
// Fake is a scripted Sensor for tests and for `just run-fake`. It replays
|
||||
// Steps in order and repeats the last one once the script runs out.
|
||||
type Fake struct {
|
||||
mu sync.Mutex
|
||||
steps []Step
|
||||
index int
|
||||
closed bool
|
||||
// Now supplies the timestamp when a Step leaves Reading.At zero.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// NewFake returns a Fake that replays steps.
|
||||
func NewFake(steps ...Step) *Fake {
|
||||
return &Fake{steps: steps, Now: time.Now}
|
||||
}
|
||||
|
||||
// Read returns the next scripted outcome.
|
||||
func (f *Fake) Read() (Reading, error) {
|
||||
f.mu.Lock()
|
||||
if f.closed {
|
||||
f.mu.Unlock()
|
||||
return Reading{}, fmt.Errorf("read after close")
|
||||
}
|
||||
if len(f.steps) == 0 {
|
||||
f.mu.Unlock()
|
||||
return Reading{}, fmt.Errorf("fake sensor has no steps")
|
||||
}
|
||||
step := f.steps[f.index]
|
||||
if f.index < len(f.steps)-1 {
|
||||
f.index++
|
||||
}
|
||||
now := f.Now
|
||||
f.mu.Unlock()
|
||||
|
||||
if step.Block > 0 {
|
||||
time.Sleep(step.Block)
|
||||
}
|
||||
if step.Err != nil {
|
||||
return Reading{}, step.Err
|
||||
}
|
||||
r := step.Reading
|
||||
if r.At.IsZero() {
|
||||
r.At = now()
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Close marks the Fake closed; later reads fail.
|
||||
func (f *Fake) Close() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Discharging returns a script that starts on mains and then drains, which is
|
||||
// the sequence the shutdown state machine must accept.
|
||||
func Discharging(mainsTicks, batteryTicks int) *Fake {
|
||||
steps := make([]Step, 0, mainsTicks+batteryTicks)
|
||||
for i := 0; i < mainsTicks; i++ {
|
||||
steps = append(steps, Step{Reading: Reading{ACPresent: true, Volts: 4.10, SOC: 100}})
|
||||
}
|
||||
for i := 0; i < batteryTicks; i++ {
|
||||
steps = append(steps, Step{Reading: Reading{
|
||||
ACPresent: false,
|
||||
Volts: 3.60 - float64(i)*0.02,
|
||||
SOC: float64(30 - i),
|
||||
}})
|
||||
}
|
||||
return NewFake(steps...)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package sensor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.mial.net/mokhtar/x1208-exporter/internal/max17040"
|
||||
"git.mial.net/mokhtar/x1208-exporter/internal/pld"
|
||||
)
|
||||
|
||||
// Hardware composes the fuel gauge and the AC-present line into one Sensor.
|
||||
//
|
||||
// Each device owns its own reacquire-on-error policy, so a fault on one bus
|
||||
// does not discard a healthy handle on the other.
|
||||
type Hardware struct {
|
||||
gauge *max17040.Gauge
|
||||
line *pld.Line
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewHardware returns a Sensor backed by the real HAT. Neither device is opened
|
||||
// until the first Read.
|
||||
func NewHardware(i2cDevice, gpioChip string, gpioLine int) *Hardware {
|
||||
return &Hardware{
|
||||
gauge: max17040.New(i2cDevice),
|
||||
line: pld.New(gpioChip, gpioLine),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Read returns a complete sample or an error. It never returns a partially
|
||||
// populated Reading: a sample that is missing a channel cannot be rendered or
|
||||
// acted on, so there is nothing useful to pass upwards.
|
||||
//
|
||||
// Both channels are attempted even when the first fails, so one tick reports
|
||||
// every fault rather than hiding the second behind the first.
|
||||
func (h *Hardware) Read() (Reading, error) {
|
||||
acPresent, acErr := h.line.Read()
|
||||
volts, soc, batErr := h.gauge.Read()
|
||||
if err := errors.Join(acErr, batErr); err != nil {
|
||||
return Reading{}, err
|
||||
}
|
||||
return Reading{
|
||||
ACPresent: acPresent,
|
||||
Volts: volts,
|
||||
SOC: soc,
|
||||
At: h.now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases both devices, reporting every failure.
|
||||
func (h *Hardware) Close() error {
|
||||
return errors.Join(h.line.Close(), h.gauge.Close())
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package sensor defines the hardware seam. Everything above this interface is
|
||||
// pure and testable on any machine; everything below it touches the Pi.
|
||||
package sensor
|
||||
|
||||
import "time"
|
||||
|
||||
// Reading is one complete sample. A Reading only exists if every channel was
|
||||
// read successfully — there is no partial Reading, because a partial sample
|
||||
// cannot be reasoned about downstream.
|
||||
type Reading struct {
|
||||
ACPresent bool
|
||||
Volts float64
|
||||
SOC float64
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Sensor reads the UPS. Read returns an error if any channel fails; callers
|
||||
// must treat the Reading as invalid in that case.
|
||||
//
|
||||
// Implementations may hold OS handles open across calls. A Read that returns
|
||||
// an error must leave the Sensor usable: the next Read reacquires whatever it
|
||||
// needs.
|
||||
type Sensor interface {
|
||||
Read() (Reading, error)
|
||||
Close() error
|
||||
}
|
||||
Reference in New Issue
Block a user