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.
147 lines
3.6 KiB
Go
147 lines
3.6 KiB
Go
// 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
|
|
}
|