Files
mokhtar e4c34784c8 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.
2026-08-09 02:01:19 +02:00

89 lines
2.0 KiB
Go

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...)
}