WIP: power module (in progress)

This commit is contained in:
jochen
2026-10-04 16:05:31 +02:00
parent 1e4706f5ba
commit 0062d3f39b
8 changed files with 636 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"context"
"fmt"
"sort"
"strings"
)
// Check is one thing the module expects of the machine.
type Check struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}
// Check says what this module expects and whether the machine meets it: its units in place and
// enabled, the runner and the moment files present, logind's file the mesh's, the inhibitor held,
// and the predecessor's leftovers.
func (m *Machine) Check(ctx context.Context, w *Watcher) map[string]any {
var checks []Check
add := func(name string, ok bool, format string, args ...any) {
checks = append(checks, Check{Name: name, OK: ok, Detail: fmt.Sprintf(format, args...)})
}
units := make([]string, 0, len(Units))
for u := range Units {
units = append(units, u)
}
sort.Strings(units)
for _, u := range units {
present := m.exists("/etc/systemd/system/" + u)
enabled, _ := m.Run(ctx, "systemctl", "is-enabled", u)
enabled = strings.TrimSpace(enabled)
want := "enabled"
if u == "mesh-power-supply.service" {
want = "static" // started by the udev rule, never by a target
}
add(u, present && enabled == want, "%s: file present %v, %s (want %s)", Units[u], present, orWord(enabled, "unknown"), want)
}
add("runner", m.exists(RunnerPath), "%s present: %v", RunnerPath, m.exists(RunnerPath))
for _, moment := range Moments {
p := MomentsDir + "/" + moment
add("moment "+moment, m.exists(p), "%s placed: %v, %d piece(s)", p, m.exists(p), len(Pieces(m.read(p)+"\n")))
}
logind := m.read(LogindFile)
add("logind's power file", strings.Contains(logind, "module power"), "%s is the mesh's (module power): %v",
LogindFile, strings.Contains(logind, "module power"))
var others []string
for _, f := range m.glob("/etc/systemd/logind.conf.d/*.conf") {
if f != LogindFile {
others = append(others, f)
}
}
add("one writer of logind's power handling", len(others) == 0,
"other logind drop-ins: %s (novox/hq ADR 0210: logind's power settings are this module's alone)", orWord(strings.Join(others, ", "), "none"))
hooks := m.glob("/etc/systemd/system-sleep/*")
add("no hand-placed sleep hooks", len(hooks) == 0,
"%s (code around sleep is a contribution to node-power now, novox/hq ADR 0211)", orWord(strings.Join(hooks, ", "), "none"))
add("udev rule", m.exists(UdevRule) || Source(m.Supplies()) == "none", "%s present: %v (only acts where there is a Mains supply)", UdevRule, m.exists(UdevRule))
if w != nil {
s := w.Snapshot()
add("sleep inhibitor", s.Inhibiting, "delay lock on sleep and shutdown held by the watcher: %v%s", s.Inhibiting, orNote(s.Problem))
add("events reach the bus", s.Pending == 0, "%d event(s) waiting for the bus", s.Pending)
}
failing := 0
for _, c := range checks {
if !c.OK {
failing++
}
}
return map[string]any{"checks": checks, "failing": failing,
"not_checked": []string{"that a contribution's own code does what it means (power_run runs one moment now)"}}
}
func orWord(s, word string) string {
if strings.TrimSpace(s) == "" {
return word
}
return s
}
func orNote(s string) string {
if s == "" {
return ""
}
return " (" + s + ")"
}
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"context"
"regexp"
"strings"
)
// Event is one thing that happened to the machine's power, as the journal recorded it.
type Event struct {
At string `json:"at"`
What string `json:"what"`
Detail string `json:"detail,omitempty"`
}
var (
sleptLine = regexp.MustCompile(`(?i)(performing sleep operation|entering sleep state|suspending system)`)
wokeLine = regexp.MustCompile(`(?i)(returned from sleep|system resumed|resumed from)`)
bootsLine = regexp.MustCompile(`^\s*(-?\d+)\s+([0-9a-f]{32})\s+(\S+\s+\S+\s+\S+(?:\s+\S+)?)\s+(?:—|-)\s+(\S+\s+\S+\s+\S+(?:\s+\S+)?)\s*$`)
)
// journal reads the system journal: as the account first, as root when the account may not.
func (m *Machine) journal(ctx context.Context, args ...string) (string, error) {
out, err := m.Run(ctx, "journalctl", args...)
if err == nil && strings.TrimSpace(out) != "" {
return out, nil
}
return m.privileged(ctx, "journalctl", args...)
}
// History is the machine's recent power events, newest last: each boot and its last entry, and every
// sleep, wake and moment run the journal recorded in the boots asked for.
func (m *Machine) History(ctx context.Context, boots int) (map[string]any, error) {
if boots < 1 {
boots = 3
}
var events []Event
list, err := m.journal(ctx, "--list-boots", "--no-pager", "-q")
if err != nil {
return nil, err
}
lines := nonEmpty(strings.Split(list, "\n"))
if len(lines) > boots {
lines = lines[len(lines)-boots:]
}
for i, l := range lines {
f := bootsLine.FindStringSubmatch(l)
if f == nil {
continue
}
events = append(events, Event{At: f[3], What: "booted", Detail: "boot " + f[1]})
if i < len(lines)-1 {
events = append(events, Event{At: f[4], What: "last entry before the next boot", Detail: "boot " + f[1]})
}
got, err := m.journal(ctx, "-b", f[1], "-q", "--no-pager", "-o", "short-iso",
"-t", "systemd-sleep", "-t", "mesh-power", "-t", "systemd-logind")
if err != nil {
continue
}
for _, e := range strings.Split(got, "\n") {
at, rest, ok := splitJournalLine(e)
if !ok {
continue
}
switch {
case sleptLine.MatchString(rest):
events = append(events, Event{At: at, What: "sleeping", Detail: rest})
case wokeLine.MatchString(rest):
events = append(events, Event{At: at, What: "woke", Detail: rest})
case strings.Contains(rest, "mesh-power"):
events = append(events, Event{At: at, What: "moment", Detail: rest})
case strings.Contains(rest, "Power key pressed") || strings.Contains(rest, "Lid closed") ||
strings.Contains(rest, "Lid opened") || strings.Contains(rest, "System is powering down") ||
strings.Contains(rest, "System is rebooting"):
events = append(events, Event{At: at, What: "logind", Detail: rest})
}
}
}
if len(events) > 200 {
events = events[len(events)-200:]
}
return map[string]any{"boots": boots, "events": orEmpty(events)}, nil
}
// splitJournalLine is a short-iso line's time and the rest (unit and message).
func splitJournalLine(line string) (string, string, bool) {
f := strings.SplitN(strings.TrimSpace(line), " ", 3)
if len(f) < 3 || !strings.Contains(f[0], "T") {
return "", "", false
}
return f[0], f[2], true
}
func nonEmpty(lines []string) []string {
var out []string
for _, l := range lines {
if strings.TrimSpace(l) != "" {
out = append(out, l)
}
}
return out
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// CommandTimeout bounds every command a tool runs: a manager that hangs on its bus must cost a tool
// call twenty seconds, never the runtime's thirty.
const CommandTimeout = 20 * time.Second
// OutputCap is the most a tool answers of one command's output.
const OutputCap = 16 * 1024
// The module's places on the machine (novox/hq ADR 0211): the runner it ships, and the files the
// controller fills with every module's code for each moment.
const (
RunnerPath = "/usr/local/lib/mesh-power/bin/power-moment"
MomentsDir = "/etc/mesh-power/moments"
LogindFile = "/etc/systemd/logind.conf.d/power.conf"
UdevRule = "/etc/udev/rules.d/90-mesh-power.rules"
)
// Moments are the power moments a module may contribute code for, in the order a machine meets them.
var Moments = []string{"after-boot", "on-mains", "on-battery", "before-sleep", "after-wake", "before-shutdown"}
// Units are the module's own units and the moment each runs.
var Units = map[string]string{
"mesh-power-after-boot.service": "after-boot",
"mesh-power-before-shutdown.service": "before-shutdown",
"mesh-power-before-sleep.service": "before-sleep",
"mesh-power-after-wake.service": "after-wake",
"mesh-power-supply.service": "on-mains / on-battery",
}
// Runner runs one command and answers its standard output. Injected, so every tool is tested against
// recorded answers rather than this machine's managers.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// ExecRunner runs a command, bounded by CommandTimeout. A failure carries what it said on stderr.
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
out := capped(stdout.String())
if ctx.Err() == context.DeadlineExceeded {
return out, fmt.Errorf("%s did not answer within %s", name, CommandTimeout)
}
if err != nil {
said := strings.TrimSpace(stderr.String())
if said == "" {
said = strings.TrimSpace(stdout.String())
}
return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, capped(said))
}
return out, nil
}
func capped(s string) string {
if len(s) <= OutputCap {
return s
}
return s[:OutputCap] + "\n… (cut at 16 KiB)"
}
// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree)
// and a way to run commands.
type Machine struct {
Root string
Run Runner
}
// Here is the machine this process runs on.
func Here() *Machine { return &Machine{Root: "/", Run: ExecRunner} }
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
func (m *Machine) read(p string) string {
b, err := os.ReadFile(m.path(p))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func (m *Machine) readInt(p string) (int64, bool) {
n, err := strconv.ParseInt(m.read(p), 10, 64)
return n, err == nil
}
func (m *Machine) exists(p string) bool {
_, err := os.Stat(m.path(p))
return err == nil
}
func (m *Machine) glob(pattern string) []string {
got, _ := filepath.Glob(m.path(pattern))
out := make([]string, 0, len(got))
for _, g := range got {
rel, err := filepath.Rel(m.Root, g)
if err != nil {
continue
}
out = append(out, "/"+rel)
}
return out
}
// privileged runs a command as root without asking for a password (sudo -n), as the module's other
// action tools do: the runtime runs as the operator's account, and these files are root's.
func (m *Machine) privileged(ctx context.Context, name string, args ...string) (string, error) {
return m.Run(ctx, "sudo", append([]string{"-n", name}, args...)...)
}
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"regexp"
"strings"
)
// A piece is one module's code for one moment, as the controller placed it (novox/hq ADR 0204 §3,
// ADR 0211 §3): under a line naming the module, in module order.
type Piece struct {
Module string `json:"module"`
Lines int `json:"lines"`
Code string `json:"code"`
}
// marker is the line the controller writes before each module's code: "# <module>", nothing else.
var marker = regexp.MustCompile(`^# ([a-z0-9][a-z0-9-]*)$`)
// Pieces splits a moment file into its modules' pieces, exactly as the runner does: a marker line
// opens a piece; what comes before the first marker is the holder's own header and runs nowhere.
func Pieces(content string) []Piece {
var out []Piece
var cur *Piece
var b strings.Builder
flush := func() {
if cur != nil {
cur.Code = b.String()
cur.Lines = strings.Count(cur.Code, "\n")
out = append(out, *cur)
}
b.Reset()
}
for _, line := range strings.SplitAfter(content, "\n") {
if m := marker.FindStringSubmatch(strings.TrimRight(line, "\n")); m != nil {
flush()
cur = &Piece{Module: m[1]}
continue
}
if cur != nil {
b.WriteString(line)
}
}
flush()
return out
}
// Hooks is every moment's pieces on this machine, read from the files the controller placed.
func (m *Machine) Hooks() map[string]any {
moments := map[string]any{}
total := 0
for _, moment := range Moments {
p := MomentsDir + "/" + moment
if !m.exists(p) {
moments[moment] = map[string]any{"placed": false}
continue
}
pieces := Pieces(m.read(p) + "\n")
total += len(pieces)
moments[moment] = map[string]any{"placed": true, "pieces": orEmpty(pieces)}
}
return map[string]any{"moments": moments, "pieces": total,
"note": "each piece runs on its own with sh as root, bounded (MESH_POWER_BOUND, default 30 s); outcomes are in the journal under mesh-power"}
}
func orEmpty[T any](s []T) []T {
if s == nil {
return []T{}
}
return s
}
+152
View File
@@ -0,0 +1,152 @@
package main
import (
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// Supply is one power supply the kernel reports.
type Supply struct {
Name string `json:"name"`
Type string `json:"type"`
Online *bool `json:"online,omitempty"`
Percent *int `json:"percent,omitempty"`
Status string `json:"status,omitempty"`
Device bool `json:"device,omitempty"` // a peripheral's battery (a mouse), not the machine's
Capacity string `json:"capacity_level,omitempty"`
}
// Supplies is every power supply under /sys/class/power_supply, by name.
func (m *Machine) Supplies() []Supply {
var out []Supply
for _, dir := range m.glob("/sys/class/power_supply/*") {
s := Supply{Name: filepath.Base(dir), Type: m.read(dir + "/type")}
if v, ok := m.readInt(dir + "/online"); ok {
on := v == 1
s.Online = &on
}
if v, ok := m.readInt(dir + "/capacity"); ok {
p := int(v)
s.Percent = &p
}
s.Status = m.read(dir + "/status")
s.Device = m.read(dir+"/scope") == "Device"
s.Capacity = m.read(dir + "/capacity_level")
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Source is where the machine draws its power from: on-mains, on-battery, or none when it has no
// Mains supply to say (a desktop, a server, a USB-C source only) — the same reading as the runner's.
func Source(supplies []Supply) string {
found := false
for _, s := range supplies {
if s.Type != "Mains" {
continue
}
found = true
if s.Online != nil && *s.Online {
return "on-mains"
}
}
if found {
return "on-battery"
}
return "none"
}
// Battery is the machine's own battery charge, or nil when it has none.
func Battery(supplies []Supply) (percent *int, status string) {
for _, s := range supplies {
if s.Type == "Battery" && !s.Device && s.Percent != nil {
return s.Percent, s.Status
}
}
return nil, ""
}
// BootTime is when the kernel started, from /proc/stat's btime.
func (m *Machine) BootTime() (time.Time, bool) {
for _, line := range strings.Split(m.read("/proc/stat"), "\n") {
if f := strings.Fields(line); len(f) == 2 && f[0] == "btime" {
if v, err := strconv.ParseInt(f[1], 10, 64); err == nil {
return time.Unix(v, 0).UTC(), true
}
}
}
return time.Time{}, false
}
// Lid is the lid's state ("open", "closed"), or "" on a machine without one.
func (m *Machine) Lid() string {
for _, p := range m.glob("/proc/acpi/button/lid/*/state") {
if f := strings.Fields(m.read(p)); len(f) > 0 {
return f[len(f)-1]
}
}
return ""
}
// LogindSettings is what logind's power keys are set to, the last file to say each one winning, in
// logind's own order: its main file, then the drop-ins by name.
func (m *Machine) LogindSettings() map[string]map[string]string {
keys := []string{"HandlePowerKey", "HandleSuspendKey", "HandleHibernateKey", "HandleLidSwitch",
"HandleLidSwitchExternalPower", "HandleLidSwitchDocked", "IdleAction", "InhibitDelayMaxSec"}
want := map[string]bool{}
for _, k := range keys {
want[k] = true
}
out := map[string]map[string]string{}
files := append([]string{"/etc/systemd/logind.conf"}, sortedGlob(m, "/usr/lib/systemd/logind.conf.d/*.conf")...)
files = append(files, sortedGlob(m, "/etc/systemd/logind.conf.d/*.conf")...)
for _, f := range files {
for _, line := range strings.Split(m.read(f), "\n") {
line = strings.TrimSpace(line)
k, v, ok := strings.Cut(line, "=")
if !ok || strings.HasPrefix(line, "#") || !want[strings.TrimSpace(k)] {
continue
}
out[strings.TrimSpace(k)] = map[string]string{"value": strings.TrimSpace(v), "from": f}
}
}
for _, k := range keys {
if _, set := out[k]; !set {
out[k] = map[string]string{"value": "(logind's default)", "from": ""}
}
}
return out
}
func sortedGlob(m *Machine, pattern string) []string {
got := m.glob(pattern)
sort.Strings(got)
return got
}
// State is the machine's power now: since when it runs, where its power comes from, the battery and
// the lid, logind's settings, and what the module's watcher last saw and holds.
func (m *Machine) State(w *Watcher) map[string]any {
supplies := m.Supplies()
out := map[string]any{"source": Source(supplies), "supplies": orEmpty(supplies)}
if bt, ok := m.BootTime(); ok {
out["booted_at"] = bt.Format(time.RFC3339)
out["up_for"] = time.Since(bt).Round(time.Second).String()
}
if p, status := Battery(supplies); p != nil {
out["battery_percent"] = *p
out["battery_status"] = status
}
if lid := m.Lid(); lid != "" {
out["lid"] = lid
}
out["logind"] = m.LogindSettings()
if w != nil {
out["watcher"] = w.Snapshot()
}
return out
}
+90
View File
@@ -0,0 +1,90 @@
#!/bin/sh
# power-moment MOMENT [--force] — run every module's code for one power moment (novox/hq ADR 0211).
# Shipped by the mesh's power module; edit the catalogue.
#
# MOMENT is after-boot, before-sleep, after-wake, before-shutdown, on-mains or on-battery, or
# `supply`, which reads the machine's power source and runs on-mains or on-battery once per change.
#
# The moment's file holds what the controller placed: each module's code under a line `# <module>`.
# Each module's piece runs on its own, with sh, as root, bounded by MESH_POWER_BOUND seconds
# (default 30) and killed 5 s after that, so one module that hangs cannot hold the machine awake or
# stop the next module. Every outcome goes to the journal under the tag mesh-power.
set -u
moment=${1:-}
force=${2:-}
moments=/etc/mesh-power/moments
bound=${MESH_POWER_BOUND:-30}
runtime=/run/mesh-power
say() { logger -t mesh-power -- "$*" 2>/dev/null || true; printf '%s\n' "$*"; }
# The power source as the kernel says it: on mains when any Mains supply is online. A machine with
# no Mains supply (a desktop, a server, a USB-C source only) has no source to change.
source_now() {
found=no
for s in /sys/class/power_supply/*; do
[ "$(cat "$s/type" 2>/dev/null)" = Mains ] || continue
found=yes
[ "$(cat "$s/online" 2>/dev/null)" = 1 ] && { echo on-mains; return; }
done
[ "$found" = yes ] && echo on-battery || echo none
}
case "$moment" in
supply)
now=$(source_now)
[ "$now" = none ] && exit 0
mkdir -p "$runtime"
last=$(cat "$runtime/source" 2>/dev/null || true)
[ "$now" = "$last" ] && [ "$force" != --force ] && exit 0
echo "$now" >"$runtime/source"
moment=$now
;;
after-boot | before-sleep | after-wake | on-mains | on-battery) ;;
before-shutdown)
# Run only while the machine is really going down. The unit that runs it is stopped at shutdown,
# and also when the module is taken off the machine; the second must not run anyone's shutdown code.
state=$(systemctl is-system-running 2>/dev/null || true)
if [ "$state" != stopping ] && [ "$force" != --force ]; then
say "moment=before-shutdown skipped: the system is $state, not stopping"
exit 0
fi
;;
*)
echo "power-moment: no moment $moment (after-boot, before-sleep, after-wake, before-shutdown, on-mains, on-battery, supply)" >&2
exit 2
;;
esac
file="$moments/$moment"
[ -r "$file" ] || { say "moment=$moment: nothing placed"; exit 0; }
work=$(mktemp -d) || exit 1
trap 'rm -rf "$work"' EXIT
# One file per module piece, in the order the controller placed them. A line that is exactly
# "# <module name>" opens a piece; lines before the first are the holder's own header.
awk -v dir="$work" '
/^# [a-z0-9][a-z0-9-]*$/ { n++; name = substr($0, 3); out = sprintf("%s/%03d-%s", dir, n, name); next }
n > 0 { print > out }
' "$file"
ran=0 failed=0
for piece in "$work"/*; do
[ -e "$piece" ] || continue
module=${piece##*/}
module=${module#*-}
start=$(date +%s)
timeout --kill-after=5 "$bound" sh "$piece" </dev/null >"$piece.out" 2>&1
rc=$?
took=$(($(date +%s) - start))
ran=$((ran + 1))
case $rc in
0) say "moment=$moment module=$module ok in ${took}s" ;;
124 | 137) failed=$((failed + 1)); say "moment=$moment module=$module ended after ${bound}s: it did not finish" ;;
*) failed=$((failed + 1)); say "moment=$moment module=$module exited $rc: $(tail -n 3 "$piece.out" | tr '\n' ' ')" ;;
esac
done
say "moment=$moment: $ran piece(s), $failed failed"
exit 0
+8
View File
@@ -0,0 +1,8 @@
module power
go 1.22
require (
git.novox.be/novox/mesh-sdk/go v0.1.7 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
)
+4
View File
@@ -0,0 +1,4 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=