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" + } + ] + } +}