Files
x1208-exporter/internal/pld/pld.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

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
}