From 791d0f62ce5b4b4e102cfde3590093336c202490 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 16:05:31 +0200 Subject: [PATCH 1/2] WIP: power module (in progress) --- modules/power/cmd/power/check.go | 87 +++++++++++++++ modules/power/cmd/power/history.go | 102 ++++++++++++++++++ modules/power/cmd/power/machine.go | 123 ++++++++++++++++++++++ modules/power/cmd/power/moments.go | 70 ++++++++++++ modules/power/cmd/power/state.go | 152 +++++++++++++++++++++++++++ modules/power/files/bin/power-moment | 90 ++++++++++++++++ modules/power/go.mod | 8 ++ modules/power/go.sum | 4 + 8 files changed, 636 insertions(+) create mode 100644 modules/power/cmd/power/check.go create mode 100644 modules/power/cmd/power/history.go create mode 100644 modules/power/cmd/power/machine.go create mode 100644 modules/power/cmd/power/moments.go create mode 100644 modules/power/cmd/power/state.go create mode 100755 modules/power/files/bin/power-moment create mode 100644 modules/power/go.mod create mode 100644 modules/power/go.sum diff --git a/modules/power/cmd/power/check.go b/modules/power/cmd/power/check.go new file mode 100644 index 0000000..30cde61 --- /dev/null +++ b/modules/power/cmd/power/check.go @@ -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 + ")" +} diff --git a/modules/power/cmd/power/history.go b/modules/power/cmd/power/history.go new file mode 100644 index 0000000..747f953 --- /dev/null +++ b/modules/power/cmd/power/history.go @@ -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 +} diff --git a/modules/power/cmd/power/machine.go b/modules/power/cmd/power/machine.go new file mode 100644 index 0000000..af26ec6 --- /dev/null +++ b/modules/power/cmd/power/machine.go @@ -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...)...) +} diff --git a/modules/power/cmd/power/moments.go b/modules/power/cmd/power/moments.go new file mode 100644 index 0000000..d082e29 --- /dev/null +++ b/modules/power/cmd/power/moments.go @@ -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: "# ", 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 +} diff --git a/modules/power/cmd/power/state.go b/modules/power/cmd/power/state.go new file mode 100644 index 0000000..f358895 --- /dev/null +++ b/modules/power/cmd/power/state.go @@ -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 +} diff --git a/modules/power/files/bin/power-moment b/modules/power/files/bin/power-moment new file mode 100755 index 0000000..e7095e4 --- /dev/null +++ b/modules/power/files/bin/power-moment @@ -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 `# `. +# 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 +# "# " 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" "$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 diff --git a/modules/power/go.mod b/modules/power/go.mod new file mode 100644 index 0000000..04c5a4b --- /dev/null +++ b/modules/power/go.mod @@ -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 +) diff --git a/modules/power/go.sum b/modules/power/go.sum new file mode 100644 index 0000000..8c37f7d --- /dev/null +++ b/modules/power/go.sum @@ -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= From 631555615229ecf3efebc0b0c8d5a0fa3dd5d72e Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 17:19:27 +0200 Subject: [PATCH 2/2] power: a machine's power as a module holding node-power; the laptop's resume and lid move onto it (hq ADR 0211) Code around sleep was written into the service manager's sleep units by the module that needed it, and the mesh could not tell a sleeping machine from a lost one. power runs every module's code for the six moments, each piece bounded, owns logind's power handling from its settings, and says booted, sleeping, woke, shutting-down and the power source on the bus, sleeping under logind's delay lock before the machine sleeps. --- modules/asus-zephyrus-g14/README.md | 8 + .../asus-zephyrus-g14/cmd/zephyrus/check.go | 9 +- modules/asus-zephyrus-g14/module.json | 56 +--- modules/power/README.md | 75 +++++ modules/power/cmd/power/live_test.go | 30 ++ modules/power/cmd/power/login1.go | 64 ++++ modules/power/cmd/power/main.go | 24 ++ modules/power/cmd/power/tools.go | 81 +++++ modules/power/cmd/power/watcher.go | 295 ++++++++++++++++++ modules/power/cmd/power/watcher_test.go | 195 ++++++++++++ modules/power/go.mod | 4 +- modules/power/module.json | 202 ++++++++++++ 12 files changed, 989 insertions(+), 54 deletions(-) create mode 100644 modules/power/README.md create mode 100644 modules/power/cmd/power/live_test.go create mode 100644 modules/power/cmd/power/login1.go create mode 100644 modules/power/cmd/power/main.go create mode 100644 modules/power/cmd/power/tools.go create mode 100644 modules/power/cmd/power/watcher.go create mode 100644 modules/power/cmd/power/watcher_test.go create mode 100644 modules/power/module.json diff --git a/modules/asus-zephyrus-g14/README.md b/modules/asus-zephyrus-g14/README.md index 108e790..bb819d4 100644 --- a/modules/asus-zephyrus-g14/README.md +++ b/modules/asus-zephyrus-g14/README.md @@ -368,3 +368,11 @@ module holds `node-hotkeys`, owns the daemon, and reads only the mesh's trigger contributes its eight trigger lines (media, panel brightness, touchpad) to that seat, so it depends on a hotkey holder being assigned beside it. Its keys still run this module's own scripts. `zephyrus_keys` reads the trigger directory as before. + +## The touchpad after waking, and the lid, move to the power module (changed 2026-10-04, novox/hq ADR 0211) + +The touchpad resume unit and its three drop-ins on the sleep services are gone. The reset is now this +module's contribution to `node-power`'s `after-wake` moment, so the module depends on the power +module. `logind.conf.d/power.conf` is the power module's. This laptop's values (suspend on the power +key and on the lid in every case) are that module's settings for this machine. The NVIDIA driver's +sleep drop-ins stay here: they must run inside the sleep transaction, which a contribution cannot. diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/check.go b/modules/asus-zephyrus-g14/cmd/zephyrus/check.go index 4353b14..1a10495 100644 --- a/modules/asus-zephyrus-g14/cmd/zephyrus/check.go +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/check.go @@ -104,9 +104,12 @@ func (m *Machine) Check(ctx context.Context, sw *Switcher) CheckReport { add("nvidia suspend and resume", strings.Contains(wants, "nvidia-suspend.service") && strings.Contains(wants, "nvidia-resume.service"), "systemd-suspend.service %s", strings.TrimSpace(wants)) - add("touchpad after resume", strings.Contains(wants, "asus-zephyrus-g14-touchpad-resume.service"), - "systemd-suspend.service wants asus-zephyrus-g14-touchpad-resume.service: %v (a daemon-reload makes a new drop-in count)", - strings.Contains(wants, "asus-zephyrus-g14-touchpad-resume.service")) + // The touchpad reset after waking is this module's contribution to node-power's after-wake moment + // (novox/hq ADR 0211), placed in the power module's moment file under a "# asus-zephyrus-g14" line. + afterWake := m.read("/etc/mesh-power/moments/after-wake") + placed := strings.Contains(afterWake, "# asus-zephyrus-g14\n") && strings.Contains(afterWake, "zephyrus-touchpad reset") + add("touchpad after resume", placed, + "the reset is placed in the power module's after-wake moment: %v (it needs the power module on this machine)", placed) if uid := m.triggerhappyUID(); uid < 0 { add("triggerhappy as the account", false, "no thd process runs: the vendor keys do nothing") } else { diff --git a/modules/asus-zephyrus-g14/module.json b/modules/asus-zephyrus-g14/module.json index 6a6a98c..d9f3a06 100644 --- a/modules/asus-zephyrus-g14/module.json +++ b/modules/asus-zephyrus-g14/module.json @@ -120,27 +120,6 @@ "mode": "0644", "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change\n# of power source, and asusd starts it on mains. It runs only when the kernel command line says\n# zephyrus.nvidia-powerd — an explicit opt-in, at boot.\n[Unit]\nConditionKernelCommandLine=zephyrus.nvidia-powerd\n" }, - { - "id": "logind-drop-ins", - "type": "directory", - "path": "/etc/systemd/logind.conf.d", - "mode": "0755" - }, - { - "id": "logind-power", - "type": "file", - "path": "/etc/systemd/logind.conf.d/power.conf", - "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The power key and the lid suspend, on battery, on mains and docked alike.\n[Login]\nHandlePowerKey=suspend\nHandleLidSwitch=suspend\nHandleLidSwitchExternalPower=suspend\nHandleLidSwitchDocked=suspend\n" - }, - { - "id": "logind", - "type": "service", - "unit": "systemd-logind.service", - "reload-on": [ - "logind-power" - ] - }, { "id": "backlight-rule", "type": "file", @@ -197,34 +176,6 @@ "mode": "0644", "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n" }, - { - "id": "touchpad-on-resume", - "type": "file", - "path": "/etc/systemd/system/asus-zephyrus-g14-touchpad-resume.service", - "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# After a resume, the touchpad's settings once more in the operator's session. X applies the module's\n# input class whenever the device appears, which covers a resume that initialises the device again;\n# this covers one that does not, as the predecessor's sleep hook did. Started by the sleep services\n# themselves (their asus-zephyrus-g14-touchpad.conf drop-ins), never enabled, and ordered after them,\n# so it runs once the machine is awake.\n[Unit]\nDescription=Touchpad settings after resume (asus-zephyrus-g14)\nAfter=systemd-suspend.service systemd-hibernate.service systemd-suspend-then-hibernate.service\n\n[Service]\nType=oneshot\nUser=${machine:account}\nExecStartPre=/bin/sleep 2\nExecStart=/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n" - }, - { - "id": "touchpad-after-suspend", - "type": "file", - "path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-touchpad.conf", - "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings again once the machine is awake (asus-zephyrus-g14-touchpad-resume.service).\n[Unit]\nWants=asus-zephyrus-g14-touchpad-resume.service\n" - }, - { - "id": "touchpad-after-hibernate", - "type": "file", - "path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-touchpad.conf", - "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings again once the machine is awake (asus-zephyrus-g14-touchpad-resume.service).\n[Unit]\nWants=asus-zephyrus-g14-touchpad-resume.service\n" - }, - { - "id": "touchpad-after-suspend-then-hibernate", - "type": "file", - "path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-touchpad.conf", - "mode": "0644", - "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings again once the machine is awake (asus-zephyrus-g14-touchpad-resume.service).\n[Unit]\nWants=asus-zephyrus-g14-touchpad-resume.service\n" - }, { "id": "i3-vendor-keys", "type": "file", @@ -268,5 +219,12 @@ "kind": "trigger", "content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n" } + ], + "shell": [ + { + "for": "after-wake", + "slot": "normal", + "code": "# The touchpad's settings again after waking, in the operator's session: X applies the module's\n# input class when the device appears, and this covers a wake that does not initialise it again.\n# Runs as root from the power module; the reset itself runs as the session's owner.\nsleep 2\nowner=$(ps -o user= -C i3 | head -n 1)\n[ -n \"$owner\" ] && runuser -u \"$owner\" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\ntrue\n" + } ] } diff --git a/modules/power/README.md b/modules/power/README.md new file mode 100644 index 0000000..74f3e27 --- /dev/null +++ b/modules/power/README.md @@ -0,0 +1,75 @@ +# power + +A machine's power (novox/hq ADR 0211). This module holds `node-power` on every machine, servers +included, because every machine boots and shuts down. It owns logind's power key and lid handling. It +runs the code other modules contribute for the power moments, and it publishes the machine's power +states on the bus. + +## What it owns + +| | | +|---|---| +| `/etc/systemd/logind.conf.d/power.conf` | the power key and the lid, from this module's settings (below); logind is reloaded, never restarted | +| `/etc/mesh-power/moments/` | for each moment, every module's code as the controller placed it (`${shell::}`) | +| `/usr/local/lib/mesh-power/bin/power-moment` | the runner: each module's piece on its own, as root, with `sh`, bounded (`MESH_POWER_BOUND`, default 30 s); every outcome in the journal under `mesh-power` | +| five units | `mesh-power-after-boot`, `-before-shutdown` (its stop is the shutdown, while the network is still up), `-before-sleep`, `-after-wake`, `-supply` | +| `/etc/udev/rules.d/90-mesh-power.rules` | a power supply's change starts `mesh-power-supply`, which runs `on-mains` or `on-battery` once per change of source | + +## The moments, and how a module adds code + +The moments are `after-boot`, `before-sleep`, `after-wake`, `before-shutdown`, `on-mains` and +`on-battery`. A module declares a `shell` entry whose `for` names the moment, in the `first`, +`normal` or `last` slot, with POSIX shell code. That entry depends on this seat (ADR 0210). The code +runs as root. Code that needs the operator's session finds it itself, as the laptop module's touchpad +reset does with `runuser`. + +**One rule for contributed code:** the runner splits the placed file at lines of the form `# `, +which the controller writes before each module's piece. A comment line of the piece's own that is +one lowercase word would split it too, so comments in contributed code use more than one word. + +Code that must run *inside* the sleep transaction, such as the NVIDIA driver's own suspend and +resume units, is not a contribution. Its module keeps its own drop-ins on the sleep services (ADR +0211, "What got harder"). + +## Settings + +`handle-power-key`, `handle-lid-switch`, `handle-lid-switch-external-power` and +`handle-lid-switch-docked`, each one of logind's actions. They have no defaults, so the mesh's layer +must be set before the first assignment. The mesh's layer carries logind's own defaults (`poweroff`, +`suspend`, `suspend`, `ignore`). A machine's layer changes them: the laptop suspends on all four. + +## Events + +| event | when | +|---|---| +| `booted` | once per boot, not per restart of the runtime (the boot id is remembered) | +| `sleeping` | before the machine sleeps: the module's watcher holds logind's delay lock, publishes, waits for the bus at most 3 s, and lets go | +| `woke` | after waking, queued until the bus is reachable | +| `shutting-down` | before a shutdown, as `sleeping` | +| `on-mains`, `on-battery` | when the power source changes, on a machine with a Mains supply | +| `battery-low` | at 10 % on battery, once per discharge | + +Each carries the time it happened. Events the bus did not take wait in order and go out when it answers +again. A machine that said `sleeping` is asleep, not out of touch. + +## Tools + +| tool | | +|---|---| +| `power_state` | boot time, source and battery, lid, logind's settings, and the watcher's lock, last sleep and wake, and queue | +| `power_hooks` | every moment's contributed pieces, with their module | +| `power_history` | boots, sleeps, wakes and moment runs from the journal | +| `power_run` | run one moment now, to test it (needs `sudo -n`) | +| `power_check` | units, runner, moment files, one logind writer, no hand-placed sleep hooks, the lock held, nothing stuck before the bus | + +## Migration + +- **The laptop:** its model module owned `logind.conf.d/power.conf` and a touchpad resume unit. Both + move here: the file to this module, and the resume to an `after-wake` contribution. +- **The desktop:** the predecessor left `logind.conf.d/brightness.conf` and + `/etc/systemd/system-sleep/xinput-reset-touchpad.sh`. Move both aside after the first push; + `power_check` names them while they remain. +- **The servers** have no logind drop-ins and no sleep hooks today. + +The lesson from the day this module was written belongs to the dbus module: a full upgrade restarted +the system bus live on a workstation, and logins hung until a reboot. diff --git a/modules/power/cmd/power/live_test.go b/modules/power/cmd/power/live_test.go new file mode 100644 index 0000000..b946826 --- /dev/null +++ b/modules/power/cmd/power/live_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +// Run on a real machine with MESH_LIVE=1: logind gives this account a delay lock, and it shows. +func TestLiveLogindGivesADelayLock(t *testing.T) { + if os.Getenv("MESH_LIVE") == "" { + t.Skip("set MESH_LIVE=1 on a machine with logind") + } + l, err := DialLogin1() + if err != nil { + t.Fatal(err) + } + defer l.Close() + f, err := l.Inhibit("sleep:shutdown", "mesh power", "live test") + if err != nil { + t.Fatal(err) + } + out, _ := exec.Command("systemd-inhibit", "--list", "--no-pager").CombinedOutput() + if !strings.Contains(string(out), "mesh power") { + t.Fatalf("not listed:\n%s", out) + } + t.Logf("%s", out) + f.Close() +} diff --git a/modules/power/cmd/power/login1.go b/modules/power/cmd/power/login1.go new file mode 100644 index 0000000..612dd26 --- /dev/null +++ b/modules/power/cmd/power/login1.go @@ -0,0 +1,64 @@ +package main + +import ( + "os" + + "github.com/godbus/dbus/v5" +) + +// systemLogin1 is logind on the system bus. +type systemLogin1 struct { + conn *dbus.Conn + out chan Signal +} + +// DialLogin1 connects to logind and listens for its sleep and shutdown announcements. +func DialLogin1() (Login1, error) { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, err + } + for _, member := range []string{"PrepareForSleep", "PrepareForShutdown"} { + if err := conn.AddMatchSignal(dbus.WithMatchInterface("org.freedesktop.login1.Manager"), + dbus.WithMatchMember(member)); err != nil { + conn.Close() + return nil, err + } + } + raw := make(chan *dbus.Signal, 16) + conn.Signal(raw) + l := &systemLogin1{conn: conn, out: make(chan Signal, 16)} + go func() { + defer close(l.out) + for s := range raw { + if len(s.Body) != 1 { + continue + } + start, ok := s.Body[0].(bool) + if !ok { + continue + } + switch s.Name { + case "org.freedesktop.login1.Manager.PrepareForSleep": + l.out <- Signal{Start: start} + case "org.freedesktop.login1.Manager.PrepareForShutdown": + l.out <- Signal{Shutdown: true, Start: start} + } + } + }() + return l, nil +} + +func (l *systemLogin1) Inhibit(what, who, why string) (*os.File, error) { + var fd dbus.UnixFD + err := l.conn.Object("org.freedesktop.login1", "/org/freedesktop/login1"). + Call("org.freedesktop.login1.Manager.Inhibit", 0, what, who, why, "delay").Store(&fd) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(fd), "logind-inhibitor"), nil +} + +func (l *systemLogin1) Signals() <-chan Signal { return l.out } + +func (l *systemLogin1) Close() { l.conn.Close() } diff --git a/modules/power/cmd/power/main.go b/modules/power/cmd/power/main.go new file mode 100644 index 0000000..1221ae7 --- /dev/null +++ b/modules/power/cmd/power/main.go @@ -0,0 +1,24 @@ +// The power module's Go bundle (novox/hq ADR 0211, ADR 0198): one process the node's runtime +// launches, serving the module's tools over MCP on stdio and running its watcher beside them, which +// publishes the machine's power states on the bus. +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func main() { + m := Here() + w := NewWatcher(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) }, DialLogin1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go w.Run(ctx) + if err := stdio.Serve("", Tools(m, w)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/modules/power/cmd/power/tools.go b/modules/power/cmd/power/tools.go new file mode 100644 index 0000000..48c0262 --- /dev/null +++ b/modules/power/cmd/power/tools.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "fmt" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Tools are the module's tools over MCP (novox/hq ADR 0211): the machine's power now, the code +// modules contributed for each moment, the recent power history, running one moment on demand, and +// the module's own check. +func Tools(m *Machine, w *Watcher) []stdio.Tool { + return []stdio.Tool{ + { + Name: "power_state", + Description: "This machine's power now: since when it runs, mains or battery and the charge, the lid, " + + "logind's power-key and lid settings in force, and what the watcher last saw: its delay lock, " + + "the last sleep and wake, and events still waiting for the bus.", + Run: func(map[string]any) (any, error) { return m.State(w), nil }, + }, + { + Name: "power_hooks", + Description: "The code every module contributed for each power moment (after boot, on mains, on battery, " + + "before sleep, after waking, before shutdown), as placed on this machine, each piece with its module.", + Run: func(map[string]any) (any, error) { return m.Hooks(), nil }, + }, + { + Name: "power_history", + Description: "The machine's recent power events from the journal: each boot, every sleep and wake, and " + + "every moment's run with each module's outcome.", + Input: map[string]any{ + "boots": map[string]any{"type": "integer", "description": "how many boots back (default 3, at most 20)"}, + }, + Run: func(args map[string]any) (any, error) { + n := 3 + if v, ok := args["boots"].(float64); ok { + n = int(v) + } + if n < 1 || n > 20 { + return nil, fmt.Errorf("boots is 1 to 20") + } + return m.History(context.Background(), n) + }, + }, + { + Name: "power_run", + Description: "Run one moment's contributed code now, as the machine would, to test it: each module's " + + "piece on its own, bounded. `supply` runs on-mains or on-battery for the source the machine is on. " + + "Answers the runner's lines. Needs sudo -n.", + Input: map[string]any{ + "type": "object", + "properties": map[string]any{ + "moment": map[string]any{"type": "string", "enum": append(append([]string(nil), Moments...), "supply"), + "description": "the moment to run"}, + }, + "required": []string{"moment"}, + }, + Run: func(args map[string]any) (any, error) { + moment, _ := args["moment"].(string) + known := moment == "supply" + for _, mo := range Moments { + known = known || mo == moment + } + if !known { + return nil, fmt.Errorf("no moment %q; the moments are %s and supply", moment, strings.Join(Moments, ", ")) + } + out, err := m.privileged(context.Background(), RunnerPath, moment, "--force") + return map[string]any{"moment": moment, "said": strings.Split(strings.TrimSpace(out), "\n")}, err + }, + }, + { + Name: "power_check", + Description: "Whether this machine's power handling is as the mesh declares it: the module's units enabled, " + + "the runner and every moment's file in place, logind's power file the mesh's and the only one, no " + + "hand-placed sleep hooks, the watcher's delay lock held and no events stuck before the bus.", + Run: func(map[string]any) (any, error) { return m.Check(context.Background(), w), nil }, + }, + } +} diff --git a/modules/power/cmd/power/watcher.go b/modules/power/cmd/power/watcher.go new file mode 100644 index 0000000..48eac7a --- /dev/null +++ b/modules/power/cmd/power/watcher.go @@ -0,0 +1,295 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// The watcher publishes the machine's power states as this module's events (novox/hq ADR 0211 §4). +// +// **`sleeping` goes out before the machine sleeps.** It holds logind's delay lock on sleep and +// shutdown; when logind announces a sleep it publishes, waits for the bus at most AnnounceWithin, +// and only then lets go, so the lock never holds a machine awake for long. Whatever could not be +// published waits in order and goes out as soon as the bus answers again — after waking, a `woke` +// queued behind a `sleeping` that never left. + +// Event types, as the module's manifest declares them in `emits`. +const ( + Booted = "booted" + Sleeping = "sleeping" + Woke = "woke" + ShuttingDown = "shutting-down" + OnMains = "on-mains" + OnBattery = "on-battery" + BatteryLow = "battery-low" +) + +// AnnounceWithin bounds how long the lock holds a sleep or a shutdown for the bus: logind's own +// InhibitDelayMaxSec is 5 s, and the machine must sleep when the bus is gone. +const AnnounceWithin = 3 * time.Second + +// LowBattery is the charge below which battery-low is said, once per discharge. +const LowBattery = 10 + +// Signal is what logind says before a sleep or a shutdown (Start true) and after a wake (false). +type Signal struct { + Shutdown bool + Start bool +} + +// Login1 is logind as the watcher uses it, behind an interface so it is tested without a bus. +type Login1 interface { + // Inhibit takes a delay lock; closing the file lets it go. + Inhibit(what, who, why string) (*os.File, error) + Signals() <-chan Signal + Close() +} + +// Emitter publishes one event and returns once the bus has it. +type Emitter func(eventType string, body any) error + +type queued struct { + Type string + Body map[string]any +} + +// Watcher is the long-running half of the module. +type Watcher struct { + m *Machine + emit Emitter + dial func() (Login1, error) + now func() time.Time + state string // where the last announced boot id is kept + poll time.Duration + mu sync.Mutex + lock *os.File + queue []queued + sleep time.Time + wake time.Time + source string + low bool + issue string +} + +// NewWatcher is a watcher for this machine, emitting through emit and reaching logind through dial. +func NewWatcher(m *Machine, emit Emitter, dial func() (Login1, error)) *Watcher { + home, _ := os.UserHomeDir() + return &Watcher{m: m, emit: emit, dial: dial, now: time.Now, poll: 10 * time.Second, + state: filepath.Join(home, ".local", "state", "mesh-power", "announced-boot")} +} + +// Snapshot is what power_state and power_check show of the watcher. +type Snapshot struct { + Inhibiting bool `json:"inhibiting"` + Pending int `json:"pending"` + Problem string `json:"problem,omitempty"` + LastSleep string `json:"last_sleep,omitempty"` + LastWake string `json:"last_wake,omitempty"` + Source string `json:"source,omitempty"` +} + +func (w *Watcher) Snapshot() Snapshot { + w.mu.Lock() + defer w.mu.Unlock() + s := Snapshot{Inhibiting: w.lock != nil, Pending: len(w.queue), Problem: w.issue, Source: w.source} + if !w.sleep.IsZero() { + s.LastSleep = w.sleep.Format(time.RFC3339) + } + if !w.wake.IsZero() { + s.LastWake = w.wake.Format(time.RFC3339) + } + return s +} + +func (w *Watcher) problem(s string) { + w.mu.Lock() + w.issue = s + w.mu.Unlock() +} + +// enqueue adds an event in order, stamped with when it happened. +func (w *Watcher) enqueue(eventType string, body map[string]any) { + if body == nil { + body = map[string]any{} + } + body["at"] = w.now().UTC().Format(time.RFC3339) + w.mu.Lock() + w.queue = append(w.queue, queued{eventType, body}) + w.mu.Unlock() +} + +// flush publishes what waits, in order, and stops at the first the bus does not take. +func (w *Watcher) flush() { + for { + w.mu.Lock() + if len(w.queue) == 0 { + w.mu.Unlock() + return + } + next := w.queue[0] + w.mu.Unlock() + if err := w.emit(next.Type, next.Body); err != nil { + w.problem("the bus did not take " + next.Type + ": " + err.Error()) + return + } + w.mu.Lock() + w.queue = w.queue[1:] + if len(w.queue) == 0 && strings.HasPrefix(w.issue, "the bus") { + w.issue = "" + } + w.mu.Unlock() + } +} + +// flushWithin publishes what waits, giving up after d: a sleep must not wait for a bus that is gone. +func (w *Watcher) flushWithin(d time.Duration) { + done := make(chan struct{}) + go func() { w.flush(); close(done) }() + select { + case <-done: + case <-time.After(d): + } +} + +func (w *Watcher) inhibit(l Login1) { + f, err := l.Inhibit("sleep:shutdown", "mesh power", "say on the bus that this machine sleeps or stops") + w.mu.Lock() + defer w.mu.Unlock() + if err != nil { + w.issue = "no delay lock: " + err.Error() + return + } + w.lock = f +} + +func (w *Watcher) release() { + w.mu.Lock() + defer w.mu.Unlock() + if w.lock != nil { + w.lock.Close() + w.lock = nil + } +} + +// bootOnce queues `booted` the first time this boot is seen, remembered across restarts of the +// runtime, so a restart is not mistaken for a boot. +func (w *Watcher) bootOnce() { + id := w.m.read("/proc/sys/kernel/random/boot_id") + if id == "" { + return + } + if last, _ := os.ReadFile(w.state); strings.TrimSpace(string(last)) == id { + return + } + body := map[string]any{"boot_id": id} + if bt, ok := w.m.BootTime(); ok { + body["booted_at"] = bt.UTC().Format(time.RFC3339) + } + w.enqueue(Booted, body) + if err := os.MkdirAll(filepath.Dir(w.state), 0o755); err == nil { + _ = os.WriteFile(w.state, []byte(id+"\n"), 0o644) + } +} + +// supply queues on-mains, on-battery and battery-low as the machine's supplies change. The first +// reading sets the baseline and says nothing; a machine with no Mains supply says nothing at all. +func (w *Watcher) supply() { + supplies := w.m.Supplies() + src := Source(supplies) + w.mu.Lock() + was := w.source + w.source = src + w.mu.Unlock() + if src != "none" && was != "" && was != src { + w.enqueue(src, nil) + } + p, _ := Battery(supplies) + if p == nil { + return + } + w.mu.Lock() + low := w.low + w.mu.Unlock() + switch { + case src == "on-battery" && *p <= LowBattery && !low: + w.mu.Lock() + w.low = true + w.mu.Unlock() + w.enqueue(BatteryLow, map[string]any{"percent": *p}) + case src == "on-mains" || *p > LowBattery+5: + w.mu.Lock() + w.low = false + w.mu.Unlock() + } +} + +// Run watches until ctx ends. Without logind it still says boots and supplies, and tries logind +// again every minute. +func (w *Watcher) Run(ctx context.Context) { + w.bootOnce() + w.supply() + tick := time.NewTicker(w.poll) + defer tick.Stop() + var l Login1 + var signals <-chan Signal + retry := time.NewTimer(0) + defer retry.Stop() + for { + select { + case <-ctx.Done(): + w.release() + if l != nil { + l.Close() + } + return + case <-retry.C: + got, err := w.dial() + if err != nil { + w.problem("logind: " + err.Error()) + retry.Reset(time.Minute) + continue + } + l, signals = got, got.Signals() + w.inhibit(l) + case s, open := <-signals: + if !open { + w.release() + l, signals = nil, nil + retry.Reset(time.Minute) + continue + } + w.handle(l, s) + case <-tick.C: + w.supply() + w.flush() + } + } +} + +func (w *Watcher) handle(l Login1, s Signal) { + switch { + case s.Shutdown && s.Start: + w.enqueue(ShuttingDown, nil) + w.flushWithin(AnnounceWithin) + w.release() + case !s.Shutdown && s.Start: + w.mu.Lock() + w.sleep = w.now() + w.mu.Unlock() + w.enqueue(Sleeping, nil) + w.flushWithin(AnnounceWithin) + w.release() + case !s.Shutdown && !s.Start: + w.mu.Lock() + w.wake = w.now() + w.mu.Unlock() + w.enqueue(Woke, nil) + w.inhibit(l) + w.supply() + w.flush() + } +} diff --git a/modules/power/cmd/power/watcher_test.go b/modules/power/cmd/power/watcher_test.go new file mode 100644 index 0000000..09d350a --- /dev/null +++ b/modules/power/cmd/power/watcher_test.go @@ -0,0 +1,195 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "sync" + "testing" + "time" +) + +type fakeLogin struct { + mu sync.Mutex + held int + signals chan Signal +} + +func (f *fakeLogin) Inhibit(what, who, why string) (*os.File, error) { + r, w, err := os.Pipe() + if err != nil { + return nil, err + } + r.Close() + f.mu.Lock() + f.held++ + f.mu.Unlock() + return w, nil +} +func (f *fakeLogin) Signals() <-chan Signal { return f.signals } +func (f *fakeLogin) Close() {} + +type bus struct { + mu sync.Mutex + down bool + got []string +} + +func (b *bus) emit(t string, _ any) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.down { + return errors.New("no bus") + } + b.got = append(b.got, t) + return nil +} + +func (b *bus) seen() []string { + b.mu.Lock() + defer b.mu.Unlock() + return append([]string(nil), b.got...) +} + +func machine(t *testing.T, files map[string]string) *Machine { + t.Helper() + root := t.TempDir() + for p, c := range files { + full := filepath.Join(root, p) + os.MkdirAll(filepath.Dir(full), 0o755) + os.WriteFile(full, []byte(c), 0o644) + } + return &Machine{Root: root} +} + +func watcher(t *testing.T, m *Machine, b *bus) *Watcher { + w := NewWatcher(m, b.emit, nil) + w.state = filepath.Join(t.TempDir(), "announced-boot") + return w +} + +func TestSleepingIsSaidBeforeTheLockGoesAndWokeAfter(t *testing.T) { + b := &bus{} + l := &fakeLogin{} + w := watcher(t, machine(t, nil), b) + w.inhibit(l) + w.handle(l, Signal{Start: true}) + if got := b.seen(); !reflect.DeepEqual(got, []string{Sleeping}) { + t.Fatalf("before sleep the bus has %v", got) + } + if w.Snapshot().Inhibiting { + t.Fatal("the delay lock is still held after the announcement: the machine would wait to sleep") + } + w.handle(l, Signal{Start: false}) + if got := b.seen(); !reflect.DeepEqual(got, []string{Sleeping, Woke}) { + t.Fatalf("after waking the bus has %v", got) + } + if !w.Snapshot().Inhibiting || l.held != 2 { + t.Fatalf("the lock is not taken again after waking: %+v, held %d", w.Snapshot(), l.held) + } +} + +func TestABusThatIsGoneDoesNotHoldTheSleepAndTheEventsFollowInOrder(t *testing.T) { + b := &bus{down: true} + l := &fakeLogin{} + w := watcher(t, machine(t, nil), b) + w.inhibit(l) + start := time.Now() + w.handle(l, Signal{Start: true}) + if time.Since(start) > AnnounceWithin+time.Second { + t.Fatal("the sleep waited longer than its bound for a bus that is gone") + } + if w.Snapshot().Inhibiting { + t.Fatal("the lock was kept while the bus was gone") + } + w.handle(l, Signal{Start: false}) + if s := w.Snapshot(); s.Pending != 2 || s.Problem == "" { + t.Fatalf("what the bus did not take is not kept and said: %+v", s) + } + b.mu.Lock() + b.down = false + b.mu.Unlock() + w.flush() + if got := b.seen(); !reflect.DeepEqual(got, []string{Sleeping, Woke}) { + t.Fatalf("the late events arrived as %v", got) + } + if s := w.Snapshot(); s.Pending != 0 || s.Problem != "" { + t.Fatalf("after the bus came back: %+v", s) + } +} + +func TestShutdownIsSaidAndLetGo(t *testing.T) { + b := &bus{} + l := &fakeLogin{} + w := watcher(t, machine(t, nil), b) + w.inhibit(l) + w.handle(l, Signal{Shutdown: true, Start: true}) + if got := b.seen(); !reflect.DeepEqual(got, []string{ShuttingDown}) || w.Snapshot().Inhibiting { + t.Fatalf("%v, inhibiting %v", got, w.Snapshot().Inhibiting) + } +} + +func TestBootIsSaidOncePerBootNotPerRestart(t *testing.T) { + m := machine(t, map[string]string{"/proc/sys/kernel/random/boot_id": "abc\n", "/proc/stat": "btime 1790000000\n"}) + b := &bus{} + w := watcher(t, m, b) + w.bootOnce() + w.flush() + again := NewWatcher(m, b.emit, nil) + again.state = w.state + again.bootOnce() + again.flush() + if got := b.seen(); !reflect.DeepEqual(got, []string{Booted}) { + t.Fatalf("a restart of the runtime was taken for a boot: %v", got) + } +} + +func supplies(online string, capacity string) map[string]string { + return map[string]string{ + "/sys/class/power_supply/ACAD/type": "Mains", "/sys/class/power_supply/ACAD/online": online, + "/sys/class/power_supply/BAT1/type": "Battery", "/sys/class/power_supply/BAT1/capacity": capacity, + "/sys/class/power_supply/BAT1/status": "Discharging", + } +} + +func TestASupplyChangeIsSaidAndALowBatteryOncePerDischarge(t *testing.T) { + b := &bus{} + m := machine(t, supplies("1", "50")) + w := watcher(t, m, b) + w.supply() // baseline: says nothing + m2 := machine(t, supplies("0", "9")) + w.m = m2 + w.supply() + w.supply() + w.flush() + if got := b.seen(); !reflect.DeepEqual(got, []string{OnBattery, BatteryLow}) { + t.Fatalf("%v", got) + } +} + +func TestAMachineWithoutMainsSaysNothingAboutSupplies(t *testing.T) { + b := &bus{} + w := watcher(t, machine(t, nil), b) + w.supply() + w.supply() + w.flush() + if got := b.seen(); len(got) != 0 { + t.Fatalf("a server announced %v", got) + } +} + +func TestRunWithoutLogindStillSaysTheBoot(t *testing.T) { + m := machine(t, map[string]string{"/proc/sys/kernel/random/boot_id": "xyz\n"}) + b := &bus{} + w := NewWatcher(m, b.emit, func() (Login1, error) { return nil, errors.New("no system bus") }) + w.state = filepath.Join(t.TempDir(), "b") + w.poll = 10 * time.Millisecond + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + w.Run(ctx) + if got := b.seen(); len(got) == 0 || got[0] != Booted || w.Snapshot().Problem == "" { + t.Fatalf("%v %+v", got, w.Snapshot()) + } +} diff --git a/modules/power/go.mod b/modules/power/go.mod index 04c5a4b..a1c9e89 100644 --- a/modules/power/go.mod +++ b/modules/power/go.mod @@ -3,6 +3,6 @@ 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 + git.novox.be/novox/mesh-sdk/go v0.1.7 + github.com/godbus/dbus/v5 v5.1.0 ) diff --git a/modules/power/module.json b/modules/power/module.json new file mode 100644 index 0000000..babec0b --- /dev/null +++ b/modules/power/module.json @@ -0,0 +1,202 @@ +{ + "module": "power", + "version": "1", + "capabilities": [ + "service-manager" + ], + "claims": [ + { + "name": "node-power", + "scope": "node" + } + ], + "emits": [ + "booted", + "sleeping", + "woke", + "shutting-down", + "on-mains", + "on-battery", + "battery-low" + ], + "tools": [ + "power_state", + "power_hooks", + "power_history", + "power_run", + "power_check" + ], + "resources": [ + { + "id": "scripts", + "type": "archive", + "path": "/usr/local/lib/mesh-power", + "artifact": "scripts" + }, + { + "id": "config-dir", + "type": "directory", + "path": "/etc/mesh-power", + "mode": "0755" + }, + { + "id": "moments-dir", + "type": "directory", + "path": "/etc/mesh-power/moments", + "mode": "0755" + }, + { + "id": "moment-after-boot", + "type": "file", + "path": "/etc/mesh-power/moments/after-boot", + "mode": "0644", + "content": "# What every module on this machine runs at the moment after-boot, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:after-boot:first}${shell:after-boot:normal}${shell:after-boot:last}" + }, + { + "id": "moment-before-sleep", + "type": "file", + "path": "/etc/mesh-power/moments/before-sleep", + "mode": "0644", + "content": "# What every module on this machine runs at the moment before-sleep, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:before-sleep:first}${shell:before-sleep:normal}${shell:before-sleep:last}" + }, + { + "id": "moment-after-wake", + "type": "file", + "path": "/etc/mesh-power/moments/after-wake", + "mode": "0644", + "content": "# What every module on this machine runs at the moment after-wake, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:after-wake:first}${shell:after-wake:normal}${shell:after-wake:last}" + }, + { + "id": "moment-before-shutdown", + "type": "file", + "path": "/etc/mesh-power/moments/before-shutdown", + "mode": "0644", + "content": "# What every module on this machine runs at the moment before-shutdown, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:before-shutdown:first}${shell:before-shutdown:normal}${shell:before-shutdown:last}" + }, + { + "id": "moment-on-mains", + "type": "file", + "path": "/etc/mesh-power/moments/on-mains", + "mode": "0644", + "content": "# What every module on this machine runs at the moment on-mains, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:on-mains:first}${shell:on-mains:normal}${shell:on-mains:last}" + }, + { + "id": "moment-on-battery", + "type": "file", + "path": "/etc/mesh-power/moments/on-battery", + "mode": "0644", + "content": "# What every module on this machine runs at the moment on-battery, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:on-battery:first}${shell:on-battery:normal}${shell:on-battery:last}" + }, + { + "id": "logind-drop-ins", + "type": "directory", + "path": "/etc/systemd/logind.conf.d", + "mode": "0755" + }, + { + "id": "logind-power", + "type": "file", + "path": "/etc/systemd/logind.conf.d/power.conf", + "mode": "0644", + "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n#\n# The power key and the lid, from this module's settings: the mesh's, then this machine's.\n[Login]\nHandlePowerKey=${setting:handle-power-key}\nHandleLidSwitch=${setting:handle-lid-switch}\nHandleLidSwitchExternalPower=${setting:handle-lid-switch-external-power}\nHandleLidSwitchDocked=${setting:handle-lid-switch-docked}\n" + }, + { + "id": "logind", + "type": "service", + "unit": "systemd-logind.service", + "reload-on": [ + "logind-power" + ] + }, + { + "id": "after-boot-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-after-boot.service", + "mode": "0644", + "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nDescription=Every module's code after boot (mesh power)\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=/usr/local/lib/mesh-power/bin/power-moment after-boot\n\n[Install]\nWantedBy=multi-user.target\n" + }, + { + "id": "before-shutdown-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-before-shutdown.service", + "mode": "0644", + "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n#\n# Started at boot and left active; stopping it is the shutdown. Ordered after the network, so at\n# shutdown it stops, and runs every module's code, while the network is still up.\n[Unit]\nDescription=Every module's code before shutdown (mesh power)\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=/bin/true\nExecStop=/usr/local/lib/mesh-power/bin/power-moment before-shutdown\nTimeoutStopSec=120\n\n[Install]\nWantedBy=multi-user.target\n" + }, + { + "id": "before-sleep-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-before-sleep.service", + "mode": "0644", + "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nDescription=Every module's code before sleep (mesh power)\nBefore=sleep.target\n\n[Service]\nType=oneshot\nExecStart=/usr/local/lib/mesh-power/bin/power-moment before-sleep\n\n[Install]\nWantedBy=sleep.target\n" + }, + { + "id": "after-wake-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-after-wake.service", + "mode": "0644", + "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nDescription=Every module's code after waking (mesh power)\nAfter=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target\n\n[Service]\nType=oneshot\nExecStart=/usr/local/lib/mesh-power/bin/power-moment after-wake\n\n[Install]\nWantedBy=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target\n" + }, + { + "id": "supply-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-supply.service", + "mode": "0644", + "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n#\n# Started by the module's udev rule when a power supply changes; runs on-mains or on-battery once per\n# change of source.\n[Unit]\nDescription=Every module's code for the power source (mesh power)\n\n[Service]\nType=oneshot\nExecStart=/usr/local/lib/mesh-power/bin/power-moment supply\n" + }, + { + "id": "udev-rule", + "type": "file", + "path": "/etc/udev/rules.d/90-mesh-power.rules", + "mode": "0644", + "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n# A change of a power supply runs the power source's moment once (the runner says nothing twice).\nSUBSYSTEM==\"power_supply\", ACTION==\"change\", RUN+=\"/usr/bin/systemctl --no-block start mesh-power-supply.service\"\n" + }, + { + "id": "after-boot", + "type": "service", + "unit": "mesh-power-after-boot.service", + "state": "running", + "boot": "enabled" + }, + { + "id": "before-shutdown", + "type": "service", + "unit": "mesh-power-before-shutdown.service", + "state": "running", + "boot": "enabled" + }, + { + "id": "before-sleep", + "type": "service", + "unit": "mesh-power-before-sleep.service", + "state": "stopped", + "boot": "enabled" + }, + { + "id": "after-wake", + "type": "service", + "unit": "mesh-power-after-wake.service", + "state": "stopped", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/power", + "binary": "power", + "loads": [ + "power" + ] + }, + { + "name": "scripts", + "kind": "archive", + "from": "files" + } + ] + } +}