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
|
||||
}
|
||||
Reference in New Issue
Block a user