344 lines
9.3 KiB
Go
344 lines
9.3 KiB
Go
package monitor
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.mial.net/mokhtar/x1208-exporter/internal/sensor"
|
|
)
|
|
|
|
func shutdownConfig() Config {
|
|
return Config{
|
|
Shutdown: true,
|
|
Volts: 3.5,
|
|
SOC: 15,
|
|
Samples: 4,
|
|
MinUptimeTicks: 2,
|
|
RequireDischarge: true,
|
|
}
|
|
}
|
|
|
|
// onBattery returns a low reading that falls by 10 mV per step, which is what
|
|
// a real discharge looks like.
|
|
func onBattery(step int) sensor.Reading {
|
|
return sensor.Reading{
|
|
ACPresent: false,
|
|
Volts: 3.40 - float64(step)*0.01,
|
|
SOC: 10,
|
|
At: time.Unix(1754661600+int64(step)*30, 0),
|
|
}
|
|
}
|
|
|
|
func onMains() sensor.Reading {
|
|
return sensor.Reading{ACPresent: true, Volts: 4.1, SOC: 100, At: time.Unix(1754661600, 0)}
|
|
}
|
|
|
|
func events(r Result) []Event {
|
|
out := make([]Event, 0, len(r.Logs))
|
|
for _, l := range r.Logs {
|
|
out = append(out, l.Event)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func contains(list []Event, want Event) bool {
|
|
for _, e := range list {
|
|
if e == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// settle runs enough healthy mains ticks to clear the startup inhibition.
|
|
func settle(t *testing.T, m *Monitor) {
|
|
t.Helper()
|
|
for i := 0; i <= m.cfg.MinUptimeTicks; i++ {
|
|
if res := m.Tick(onMains(), nil); res.Shutdown {
|
|
t.Fatal("shutdown fired while on mains")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHealthyTicksLogNothingAfterStartup(t *testing.T) {
|
|
m := New(Config{})
|
|
|
|
first := m.Tick(onMains(), nil)
|
|
if !contains(events(first), EventStartup) {
|
|
t.Error("first tick must log startup")
|
|
}
|
|
|
|
for i := 0; i < 5; i++ {
|
|
if res := m.Tick(onMains(), nil); len(res.Logs) != 0 {
|
|
t.Errorf("healthy tick %d logged %v, want nothing", i, events(res))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestACTransitionLogsOncePerChange(t *testing.T) {
|
|
m := New(Config{})
|
|
m.Tick(onMains(), nil)
|
|
|
|
res := m.Tick(onBattery(0), nil)
|
|
if !contains(events(res), EventACChanged) {
|
|
t.Fatal("losing AC must log a transition")
|
|
}
|
|
if res := m.Tick(onBattery(1), nil); len(res.Logs) != 0 {
|
|
t.Errorf("staying on battery logged %v, want nothing", events(res))
|
|
}
|
|
if res := m.Tick(onMains(), nil); !contains(events(res), EventACChanged) {
|
|
t.Error("regaining AC must log a transition")
|
|
}
|
|
}
|
|
|
|
func TestFailedTickKeepsPreviousSampleAndCounts(t *testing.T) {
|
|
m := New(Config{})
|
|
m.Tick(onMains(), nil)
|
|
|
|
res := m.Tick(sensor.Reading{}, errors.New("i2c wedged"))
|
|
if res.Healthy {
|
|
t.Error("failed tick must report unhealthy")
|
|
}
|
|
if res.Failures != 1 {
|
|
t.Errorf("failures = %d, want 1", res.Failures)
|
|
}
|
|
if !res.HaveSample || res.Sample.Volts != 4.1 {
|
|
t.Error("failed tick must carry the previous complete sample forward")
|
|
}
|
|
if !contains(events(res), EventErrorEnter) {
|
|
t.Error("first failure must log error_enter")
|
|
}
|
|
|
|
if res := m.Tick(sensor.Reading{}, errors.New("still wedged")); contains(events(res), EventErrorEnter) {
|
|
t.Error("a continuing error must not log error_enter again")
|
|
}
|
|
if res := m.Tick(onMains(), nil); !contains(events(res), EventErrorExit) {
|
|
t.Error("recovery must log error_exit")
|
|
}
|
|
}
|
|
|
|
func TestShutdownDisabledByDefault(t *testing.T) {
|
|
m := New(Config{})
|
|
for i := 0; i < 20; i++ {
|
|
if m.Tick(onBattery(i), nil).Shutdown {
|
|
t.Fatal("shutdown fired with an empty Config; it must be opt-in")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShutdownFiresOnSustainedDischarge(t *testing.T) {
|
|
m := New(shutdownConfig())
|
|
settle(t, m)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
if m.Tick(onBattery(i), nil).Shutdown {
|
|
t.Fatalf("shutdown fired on qualifying tick %d, before the sample count", i+1)
|
|
}
|
|
}
|
|
res := m.Tick(onBattery(3), nil)
|
|
if !res.Shutdown {
|
|
t.Fatal("shutdown must fire on the fourth consecutive qualifying tick")
|
|
}
|
|
if !contains(events(res), EventShutdownTriggered) {
|
|
t.Error("firing must log shutdown_triggered")
|
|
}
|
|
if m.Tick(onBattery(4), nil).Shutdown {
|
|
t.Error("shutdown must fire only once")
|
|
}
|
|
}
|
|
|
|
// A wedged sensor repeats one plausible low value. Consecutive identical
|
|
// samples are one fault observed four times, not four confirmations.
|
|
func TestStuckSensorNeverTriggersShutdown(t *testing.T) {
|
|
m := New(shutdownConfig())
|
|
settle(t, m)
|
|
|
|
stuck := sensor.Reading{ACPresent: false, Volts: 3.40, SOC: 10, At: time.Unix(1754661600, 0)}
|
|
for i := 0; i < 50; i++ {
|
|
if m.Tick(stuck, nil).Shutdown {
|
|
t.Fatalf("shutdown fired at tick %d on a constant voltage", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// "low, low, long outage, low, low" is not a sustained low battery.
|
|
func TestErrorResetsArmingWindow(t *testing.T) {
|
|
m := New(shutdownConfig())
|
|
settle(t, m)
|
|
|
|
m.Tick(onBattery(0), nil)
|
|
m.Tick(onBattery(1), nil)
|
|
|
|
res := m.Tick(sensor.Reading{}, errors.New("bus error"))
|
|
if !contains(events(res), EventShutdownDisarmed) {
|
|
t.Error("a failed tick must disarm, not pause, the shutdown window")
|
|
}
|
|
|
|
for i := 2; i < 5; i++ {
|
|
if m.Tick(onBattery(i), nil).Shutdown {
|
|
t.Fatalf("shutdown fired at tick %d; the window must restart after an error", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestACReturnDisarms(t *testing.T) {
|
|
m := New(shutdownConfig())
|
|
settle(t, m)
|
|
|
|
m.Tick(onBattery(0), nil)
|
|
m.Tick(onBattery(1), nil)
|
|
|
|
res := m.Tick(onMains(), nil)
|
|
if !contains(events(res), EventShutdownDisarmed) {
|
|
t.Error("AC returning must disarm")
|
|
}
|
|
for i := 2; i < 5; i++ {
|
|
if m.Tick(onBattery(i), nil).Shutdown {
|
|
t.Fatalf("shutdown fired at tick %d; the window must restart after AC returned", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A flapping AC line must not accumulate toward a poweroff.
|
|
func TestFlappingACNeverAccumulates(t *testing.T) {
|
|
m := New(shutdownConfig())
|
|
settle(t, m)
|
|
|
|
for i := 0; i < 40; i++ {
|
|
var res Result
|
|
if i%2 == 0 {
|
|
res = m.Tick(onBattery(i), nil)
|
|
} else {
|
|
res = m.Tick(onMains(), nil)
|
|
}
|
|
if res.Shutdown {
|
|
t.Fatalf("shutdown fired at tick %d on a flapping AC line", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A restart loop must not power the machine off using readings taken before
|
|
// the hardware settled.
|
|
func TestStartupInhibitionBlocksImmediateShutdown(t *testing.T) {
|
|
m := New(shutdownConfig())
|
|
for i := 0; i < 2; i++ {
|
|
if m.Tick(onBattery(i), nil).Shutdown {
|
|
t.Fatalf("shutdown fired at tick %d, inside the startup inhibition", i+1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The worst remaining failure: the AC line reads 0 while mains is actually
|
|
// connected. The charger then holds cell voltage flat or rising, which is what
|
|
// the discharge guard exists to catch. Without it a single stuck GPIO line
|
|
// powers the machine off during normal operation.
|
|
func TestFalseACLossWhileChargingNeverTriggersShutdown(t *testing.T) {
|
|
m := New(shutdownConfig())
|
|
settle(t, m)
|
|
|
|
for i := 0; i < 60; i++ {
|
|
r := sensor.Reading{
|
|
ACPresent: false, // the line is lying
|
|
Volts: 3.40 + float64(i)*0.005, // but the charger is working
|
|
SOC: 10,
|
|
}
|
|
if m.Tick(r, nil).Shutdown {
|
|
t.Fatalf("shutdown fired at tick %d while voltage was RISING; "+
|
|
"a false AC reading must not power the machine off", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Guard against a future change that quietly drops the discharge requirement:
|
|
// with it disabled, the same false reading DOES power the machine off. This
|
|
// test documents the consequence rather than endorsing the setting.
|
|
func TestWithoutDischargeGuardAFalseACReadingIsEnough(t *testing.T) {
|
|
cfg := shutdownConfig()
|
|
cfg.RequireDischarge = false
|
|
m := New(cfg)
|
|
settle(t, m)
|
|
|
|
fired := false
|
|
for i := 0; i < 10 && !fired; i++ {
|
|
r := sensor.Reading{ACPresent: false, Volts: 3.40, SOC: 10}
|
|
fired = m.Tick(r, nil).Shutdown
|
|
}
|
|
if !fired {
|
|
t.Error("expected the unguarded config to act on a constant low reading; " +
|
|
"if this changed, update the RequireDischarge documentation")
|
|
}
|
|
}
|
|
|
|
// A failed poweroff must not count as a completed one. If it latched, the
|
|
// battery would reach cell cutoff and the HAT would hard-cut the machine —
|
|
// exactly what the feature prevents.
|
|
func TestFailedActuationRetriesOnTheNextTick(t *testing.T) {
|
|
m := New(shutdownConfig())
|
|
settle(t, m)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
m.Tick(onBattery(i), nil)
|
|
}
|
|
if !m.Tick(onBattery(3), nil).Shutdown {
|
|
t.Fatal("expected the first shutdown")
|
|
}
|
|
|
|
// systemctl poweroff failed: polkit denied it, or D-Bus was busy.
|
|
m.ActuationFailed()
|
|
|
|
res := m.Tick(onBattery(4), nil)
|
|
if !res.Shutdown {
|
|
t.Fatal("a failed poweroff must be retried on the next qualifying tick")
|
|
}
|
|
if m.Retries() != 1 {
|
|
t.Errorf("Retries() = %d, want 1", m.Retries())
|
|
}
|
|
|
|
// Still latched while the retry is outstanding.
|
|
if m.Tick(onBattery(5), nil).Shutdown {
|
|
t.Error("must not fire again before the second attempt is reported failed")
|
|
}
|
|
}
|
|
|
|
// Retrying must not defeat the safety guards: if AC returns between attempts,
|
|
// the machine must stay up.
|
|
func TestRetryStillRespectsACReturn(t *testing.T) {
|
|
m := New(shutdownConfig())
|
|
settle(t, m)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
m.Tick(onBattery(i), nil)
|
|
}
|
|
if !m.Tick(onBattery(3), nil).Shutdown {
|
|
t.Fatal("expected the first shutdown")
|
|
}
|
|
m.ActuationFailed()
|
|
|
|
if m.Tick(onMains(), nil).Shutdown {
|
|
t.Fatal("AC returned; a pending retry must not power the machine off")
|
|
}
|
|
for i := 4; i < 7; i++ {
|
|
if m.Tick(onBattery(i), nil).Shutdown {
|
|
t.Fatalf("fired at tick %d; the window must restart after AC returned", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLowSOCAloneCanArm(t *testing.T) {
|
|
m := New(shutdownConfig())
|
|
settle(t, m)
|
|
|
|
// Voltage stays above the threshold; only state of charge is low.
|
|
for i := 0; i < 3; i++ {
|
|
r := sensor.Reading{ACPresent: false, Volts: 3.9 - float64(i)*0.01, SOC: 5}
|
|
if m.Tick(r, nil).Shutdown {
|
|
t.Fatalf("fired early at tick %d", i)
|
|
}
|
|
}
|
|
r := sensor.Reading{ACPresent: false, Volts: 3.87, SOC: 5}
|
|
if !m.Tick(r, nil).Shutdown {
|
|
t.Error("a sustained low state of charge must trigger shutdown")
|
|
}
|
|
}
|