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.
55 lines
1.5 KiB
Go
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())
|
|
}
|