// 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 }