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.
87 lines
2.0 KiB
Go
87 lines
2.0 KiB
Go
// Package pld reads the X1208 power-loss-detect line.
|
|
//
|
|
// The HAT drives GPIO 6 high while mains power is present and low while the
|
|
// Pi runs on cells.
|
|
package pld
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"github.com/warthog618/go-gpiocdev"
|
|
)
|
|
|
|
const (
|
|
// DefaultChip is addressed by name, not by /dev path, so this does not
|
|
// depend on any distribution-specific symlink.
|
|
DefaultChip = "gpiochip0"
|
|
// DefaultLine is the power-loss-detect line on the X1208.
|
|
DefaultLine = 6
|
|
|
|
consumer = "x1208-exporter"
|
|
)
|
|
|
|
// Line holds the GPIO request open across reads. The kernel grants the request
|
|
// exclusively, so reacquiring it per read risks EBUSY against our own
|
|
// not-yet-released handle.
|
|
type Line struct {
|
|
chip string
|
|
offset int
|
|
line *gpiocdev.Line
|
|
}
|
|
|
|
// New returns a Line that has not yet been requested. The first Read requests it.
|
|
func New(chip string, offset int) *Line {
|
|
if chip == "" {
|
|
chip = DefaultChip
|
|
}
|
|
return &Line{chip: chip, offset: offset}
|
|
}
|
|
|
|
func (l *Line) ensureOpen() error {
|
|
if l.line != nil {
|
|
return nil
|
|
}
|
|
line, err := gpiocdev.RequestLine(l.chip, l.offset,
|
|
gpiocdev.AsInput,
|
|
gpiocdev.WithConsumer(consumer))
|
|
if err != nil {
|
|
return fmt.Errorf("request %s line %d: %w", l.chip, l.offset, err)
|
|
}
|
|
l.line = line
|
|
return nil
|
|
}
|
|
|
|
// Close releases the line. It is safe to call more than once.
|
|
func (l *Line) Close() error {
|
|
if l.line == nil {
|
|
return nil
|
|
}
|
|
line := l.line
|
|
l.line = nil
|
|
return line.Close()
|
|
}
|
|
|
|
// Read reports whether mains power is present.
|
|
//
|
|
// On error the line is released so the next Read requests a fresh one.
|
|
//
|
|
// A read that succeeds is not proof that the value is meaningful: a floating
|
|
// or misconfigured line returns 0 with no error. Callers that act on "no AC"
|
|
// must corroborate it, not trust this alone.
|
|
func (l *Line) Read() (acPresent bool, err error) {
|
|
if err := l.ensureOpen(); err != nil {
|
|
return false, err
|
|
}
|
|
defer func() {
|
|
if err != nil {
|
|
_ = l.Close()
|
|
}
|
|
}()
|
|
|
|
v, err := l.line.Value()
|
|
if err != nil {
|
|
return false, fmt.Errorf("read %s line %d: %w", l.chip, l.offset, err)
|
|
}
|
|
return v != 0, nil
|
|
}
|