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:
2026-08-09 02:01:19 +02:00
parent aae6cee886
commit e4c34784c8
23 changed files with 1774 additions and 321 deletions
+146
View File
@@ -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
}