Files
x1208-exporter/internal/sensor/hardware.go
T
mokhtar e4c34784c8 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.
2026-08-09 02:01:19 +02:00

55 lines
1.5 KiB
Go

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())
}