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,146 @@
|
||||
// Package max17040 reads the fuel gauge on the X1208 UPS HAT over i2c.
|
||||
package max17040
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultDevice is the i2c bus the HAT sits on. dtparam=i2c_arm=on.
|
||||
DefaultDevice = "/dev/i2c-1"
|
||||
|
||||
addr = 0x36
|
||||
regVoltage = 0x02
|
||||
regCapacity = 0x04
|
||||
i2cSlave = 0x0703 // I2C_SLAVE
|
||||
|
||||
maxRetries = 3
|
||||
retryDelay = 20 * time.Millisecond
|
||||
|
||||
voltageScale = 1.25 / 1000 / 16
|
||||
socScale = 1.0 / 256
|
||||
|
||||
minPlausibleVolts = 2.0
|
||||
maxPlausibleVolts = 5.0
|
||||
maxPlausibleSOC = 110
|
||||
)
|
||||
|
||||
// Gauge holds the i2c file descriptor open across reads. Opening per read cost
|
||||
// two extra syscalls every tick for no benefit.
|
||||
type Gauge struct {
|
||||
device string
|
||||
fd int
|
||||
open bool
|
||||
}
|
||||
|
||||
// New returns a Gauge that has not yet opened the bus. The first Read opens it.
|
||||
func New(device string) *Gauge {
|
||||
if device == "" {
|
||||
device = DefaultDevice
|
||||
}
|
||||
return &Gauge{device: device, fd: -1}
|
||||
}
|
||||
|
||||
func (g *Gauge) ensureOpen() error {
|
||||
if g.open {
|
||||
return nil
|
||||
}
|
||||
fd, err := unix.Open(g.device, unix.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", g.device, err)
|
||||
}
|
||||
if err := unix.IoctlSetInt(fd, i2cSlave, addr); err != nil {
|
||||
unix.Close(fd)
|
||||
return fmt.Errorf("ioctl I2C_SLAVE 0x%02x: %w", addr, err)
|
||||
}
|
||||
g.fd = fd
|
||||
g.open = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases the bus. It is safe to call on an already-closed Gauge, and
|
||||
// safe to call more than once.
|
||||
func (g *Gauge) Close() error {
|
||||
if !g.open {
|
||||
return nil
|
||||
}
|
||||
g.open = false
|
||||
fd := g.fd
|
||||
g.fd = -1
|
||||
return unix.Close(fd)
|
||||
}
|
||||
|
||||
// Read returns cell voltage in volts and state of charge in percent.
|
||||
//
|
||||
// On any error the bus is closed, so the next Read reopens from scratch. This
|
||||
// heals a wedged descriptor without a process restart.
|
||||
func (g *Gauge) Read() (volts float64, soc float64, err error) {
|
||||
if err := g.ensureOpen(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = g.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
rawV, err := g.readWordRetry(regVoltage, func(v uint16) bool {
|
||||
f := float64(v) * voltageScale
|
||||
return f > minPlausibleVolts && f < maxPlausibleVolts
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("voltage: %w", err)
|
||||
}
|
||||
|
||||
rawC, err := g.readWordRetry(regCapacity, func(v uint16) bool {
|
||||
return float64(v)*socScale <= maxPlausibleSOC
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("capacity: %w", err)
|
||||
}
|
||||
|
||||
return float64(rawV) * voltageScale, float64(rawC) * socScale, nil
|
||||
}
|
||||
|
||||
// readWord writes the register pointer, then reads the word back.
|
||||
//
|
||||
// This is deliberately NOT an I2C_RDWR combined transaction. The bcm2835 i2c
|
||||
// controller has known repeated-start limitations, and this two-step sequence
|
||||
// is what has been running against this HAT. The sequence is only safe because
|
||||
// nothing else on the system opens /dev/i2c-1 — if that ever changes, another
|
||||
// process can move the register pointer between the write and the read.
|
||||
func (g *Gauge) readWord(reg byte) (uint16, error) {
|
||||
if _, err := unix.Write(g.fd, []byte{reg}); err != nil {
|
||||
return 0, fmt.Errorf("write reg 0x%02x: %w", reg, err)
|
||||
}
|
||||
buf := make([]byte, 2)
|
||||
n, err := unix.Read(g.fd, buf)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
if n != 2 {
|
||||
return 0, fmt.Errorf("short read: %d bytes", n)
|
||||
}
|
||||
return binary.BigEndian.Uint16(buf), nil
|
||||
}
|
||||
|
||||
func (g *Gauge) readWordRetry(reg byte, plausible func(uint16) bool) (uint16, error) {
|
||||
var lastErr error
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
v, err := g.readWord(reg)
|
||||
switch {
|
||||
case err != nil:
|
||||
lastErr = err
|
||||
case !plausible(v):
|
||||
lastErr = fmt.Errorf("value out of range: 0x%04x", v)
|
||||
default:
|
||||
return v, nil
|
||||
}
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
return 0, lastErr
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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}}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package pld reads the X1208 power-loss-detect line.
|
||||
//
|
||||
// The HAT drives GPIO 6 high while mains power is present and low while the
|
||||
// Pi runs on cells.
|
||||
package pld
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/warthog618/go-gpiocdev"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultChip is addressed by name, not by /dev path, so this does not
|
||||
// depend on any distribution-specific symlink.
|
||||
DefaultChip = "gpiochip0"
|
||||
// DefaultLine is the power-loss-detect line on the X1208.
|
||||
DefaultLine = 6
|
||||
|
||||
consumer = "x1208-exporter"
|
||||
)
|
||||
|
||||
// Line holds the GPIO request open across reads. The kernel grants the request
|
||||
// exclusively, so reacquiring it per read risks EBUSY against our own
|
||||
// not-yet-released handle.
|
||||
type Line struct {
|
||||
chip string
|
||||
offset int
|
||||
line *gpiocdev.Line
|
||||
}
|
||||
|
||||
// New returns a Line that has not yet been requested. The first Read requests it.
|
||||
func New(chip string, offset int) *Line {
|
||||
if chip == "" {
|
||||
chip = DefaultChip
|
||||
}
|
||||
return &Line{chip: chip, offset: offset}
|
||||
}
|
||||
|
||||
func (l *Line) ensureOpen() error {
|
||||
if l.line != nil {
|
||||
return nil
|
||||
}
|
||||
line, err := gpiocdev.RequestLine(l.chip, l.offset,
|
||||
gpiocdev.AsInput,
|
||||
gpiocdev.WithConsumer(consumer))
|
||||
if err != nil {
|
||||
return fmt.Errorf("request %s line %d: %w", l.chip, l.offset, err)
|
||||
}
|
||||
l.line = line
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases the line. It is safe to call more than once.
|
||||
func (l *Line) Close() error {
|
||||
if l.line == nil {
|
||||
return nil
|
||||
}
|
||||
line := l.line
|
||||
l.line = nil
|
||||
return line.Close()
|
||||
}
|
||||
|
||||
// Read reports whether mains power is present.
|
||||
//
|
||||
// On error the line is released so the next Read requests a fresh one.
|
||||
//
|
||||
// A read that succeeds is not proof that the value is meaningful: a floating
|
||||
// or misconfigured line returns 0 with no error. Callers that act on "no AC"
|
||||
// must corroborate it, not trust this alone.
|
||||
func (l *Line) Read() (acPresent bool, err error) {
|
||||
if err := l.ensureOpen(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = l.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
v, err := l.line.Value()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read %s line %d: %w", l.chip, l.offset, err)
|
||||
}
|
||||
return v != 0, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package textfile renders and writes the Prometheus textfile that
|
||||
// node-exporter's textfile collector scrapes.
|
||||
package textfile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.mial.net/mokhtar/x1208-exporter/internal/sensor"
|
||||
)
|
||||
|
||||
// Render returns the exposition text for the last complete sample.
|
||||
//
|
||||
// The first four metrics are byte-identical to the pre-daemon exporter. Their
|
||||
// names and value formatting are referenced by Grafana alerts and by the
|
||||
// dashboard, so they must not change. The golden test locks this.
|
||||
//
|
||||
// healthy reports the outcome of the most recent read attempt; failures counts
|
||||
// failed attempts since process start. Both are additive and safe to scrape.
|
||||
//
|
||||
// When a read fails the caller re-renders the PREVIOUS sample with healthy=0.
|
||||
// rpi_ups_last_update_seconds therefore stops advancing, which is what the
|
||||
// RpiUpsExporterStale alert measures, while the failure stays visible instead
|
||||
// of being hidden behind a frozen file.
|
||||
func Render(s sensor.Reading, healthy bool, failures uint64) string {
|
||||
ac := 0
|
||||
if s.ACPresent {
|
||||
ac = 1
|
||||
}
|
||||
health := 0
|
||||
if healthy {
|
||||
health = 1
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# HELP rpi_ups_last_update_seconds Unix timestamp of last successful exporter run.\n")
|
||||
b.WriteString("# TYPE rpi_ups_last_update_seconds gauge\n")
|
||||
fmt.Fprintf(&b, "rpi_ups_last_update_seconds %d\n", s.At.Unix())
|
||||
b.WriteString("# HELP rpi_ups_ac_power AC adapter present (1=plugged in, 0=on battery).\n")
|
||||
b.WriteString("# TYPE rpi_ups_ac_power gauge\n")
|
||||
fmt.Fprintf(&b, "rpi_ups_ac_power %d\n", ac)
|
||||
b.WriteString("# HELP rpi_ups_voltage_volts Battery cell voltage from MAX17040 fuel gauge.\n")
|
||||
b.WriteString("# TYPE rpi_ups_voltage_volts gauge\n")
|
||||
fmt.Fprintf(&b, "rpi_ups_voltage_volts %.3f\n", s.Volts)
|
||||
b.WriteString("# HELP rpi_ups_battery_percent Battery state of charge from MAX17040 fuel gauge.\n")
|
||||
b.WriteString("# TYPE rpi_ups_battery_percent gauge\n")
|
||||
fmt.Fprintf(&b, "rpi_ups_battery_percent %.2f\n", s.SOC)
|
||||
b.WriteString("# HELP rpi_ups_sensor_healthy Whether the most recent UPS read succeeded (1=yes, 0=no).\n")
|
||||
b.WriteString("# TYPE rpi_ups_sensor_healthy gauge\n")
|
||||
fmt.Fprintf(&b, "rpi_ups_sensor_healthy %d\n", health)
|
||||
b.WriteString("# HELP rpi_ups_read_failures_total Cumulative failed UPS reads since exporter start.\n")
|
||||
b.WriteString("# TYPE rpi_ups_read_failures_total counter\n")
|
||||
fmt.Fprintf(&b, "rpi_ups_read_failures_total %d\n", failures)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// WriteAtomic writes content to path via a temporary file and a rename, so a
|
||||
// scrape never observes a half-written file.
|
||||
func WriteAtomic(path, content string) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".x1208.*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := tmp.WriteString(content); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(0o644); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, path)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package textfile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mial.net/mokhtar/x1208-exporter/internal/sensor"
|
||||
)
|
||||
|
||||
func sample() sensor.Reading {
|
||||
return sensor.Reading{
|
||||
ACPresent: true,
|
||||
Volts: 4.0625,
|
||||
SOC: 97.65625,
|
||||
At: time.Unix(1754661600, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// The four original metrics must render exactly as the oneshot exporter did.
|
||||
// Grafana alerts and the dashboard key on these names and this formatting.
|
||||
//
|
||||
// The sample values are exact binary fractions that land on a rounding tie, so
|
||||
// this also locks the rounding mode: Go rounds half to even, which turns
|
||||
// 4.0625 into 4.062 and 97.65625 into 97.66.
|
||||
const goldenHealthy = `# HELP rpi_ups_last_update_seconds Unix timestamp of last successful exporter run.
|
||||
# TYPE rpi_ups_last_update_seconds gauge
|
||||
rpi_ups_last_update_seconds 1754661600
|
||||
# HELP rpi_ups_ac_power AC adapter present (1=plugged in, 0=on battery).
|
||||
# TYPE rpi_ups_ac_power gauge
|
||||
rpi_ups_ac_power 1
|
||||
# HELP rpi_ups_voltage_volts Battery cell voltage from MAX17040 fuel gauge.
|
||||
# TYPE rpi_ups_voltage_volts gauge
|
||||
rpi_ups_voltage_volts 4.062
|
||||
# HELP rpi_ups_battery_percent Battery state of charge from MAX17040 fuel gauge.
|
||||
# TYPE rpi_ups_battery_percent gauge
|
||||
rpi_ups_battery_percent 97.66
|
||||
# HELP rpi_ups_sensor_healthy Whether the most recent UPS read succeeded (1=yes, 0=no).
|
||||
# TYPE rpi_ups_sensor_healthy gauge
|
||||
rpi_ups_sensor_healthy 1
|
||||
# HELP rpi_ups_read_failures_total Cumulative failed UPS reads since exporter start.
|
||||
# TYPE rpi_ups_read_failures_total counter
|
||||
rpi_ups_read_failures_total 0
|
||||
`
|
||||
|
||||
func TestRenderGolden(t *testing.T) {
|
||||
got := Render(sample(), true, 0)
|
||||
if got != goldenHealthy {
|
||||
t.Errorf("render mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, goldenHealthy)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed read must not advance the timestamp. That frozen value is exactly
|
||||
// what RpiUpsExporterStale measures.
|
||||
func TestRenderUnhealthyFreezesTimestamp(t *testing.T) {
|
||||
last := sample()
|
||||
got := Render(last, false, 3)
|
||||
|
||||
if !strings.Contains(got, "rpi_ups_last_update_seconds 1754661600\n") {
|
||||
t.Error("timestamp must stay at the last complete sample")
|
||||
}
|
||||
if !strings.Contains(got, "rpi_ups_sensor_healthy 0\n") {
|
||||
t.Error("unhealthy sample must report rpi_ups_sensor_healthy 0")
|
||||
}
|
||||
if !strings.Contains(got, "rpi_ups_read_failures_total 3\n") {
|
||||
t.Error("failure count must be visible while the sensor is failing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderACAbsent(t *testing.T) {
|
||||
s := sample()
|
||||
s.ACPresent = false
|
||||
if !strings.Contains(Render(s, true, 0), "rpi_ups_ac_power 0\n") {
|
||||
t.Error("AC absent must render rpi_ups_ac_power 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAtomicReplacesContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sub", "x1208.prom")
|
||||
|
||||
if err := WriteAtomic(path, "first\n"); err != nil {
|
||||
t.Fatalf("first write: %v", err)
|
||||
}
|
||||
if err := WriteAtomic(path, "second\n"); err != nil {
|
||||
t.Fatalf("second write: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if string(got) != "second\n" {
|
||||
t.Errorf("content = %q, want %q", got, "second\n")
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o644 {
|
||||
t.Errorf("mode = %v, want 0644 (node-exporter must be able to read it)", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// A rename-based write must never leave temporary files behind for the
|
||||
// collector to trip over.
|
||||
func TestWriteAtomicLeavesNoTempFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "x1208.prom")
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := WriteAtomic(path, "x\n"); err != nil {
|
||||
t.Fatalf("write %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("directory holds %d entries, want 1", len(entries))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user