Merge pull request 'power: a machine's power as a module holding node-power (hq ADR 0211)' (#284) from feat/power-module into main
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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/<moment>` | for each moment, every module's code as the controller placed it (`${shell:<moment>:<slot>}`) |
|
||||
| `/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 `# <module>`,
|
||||
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.
|
||||
@@ -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 + ")"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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...)...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A piece is one module's code for one moment, as the controller placed it (novox/hq ADR 0204 §3,
|
||||
// ADR 0211 §3): under a line naming the module, in module order.
|
||||
type Piece struct {
|
||||
Module string `json:"module"`
|
||||
Lines int `json:"lines"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// marker is the line the controller writes before each module's code: "# <module>", nothing else.
|
||||
var marker = regexp.MustCompile(`^# ([a-z0-9][a-z0-9-]*)$`)
|
||||
|
||||
// Pieces splits a moment file into its modules' pieces, exactly as the runner does: a marker line
|
||||
// opens a piece; what comes before the first marker is the holder's own header and runs nowhere.
|
||||
func Pieces(content string) []Piece {
|
||||
var out []Piece
|
||||
var cur *Piece
|
||||
var b strings.Builder
|
||||
flush := func() {
|
||||
if cur != nil {
|
||||
cur.Code = b.String()
|
||||
cur.Lines = strings.Count(cur.Code, "\n")
|
||||
out = append(out, *cur)
|
||||
}
|
||||
b.Reset()
|
||||
}
|
||||
for _, line := range strings.SplitAfter(content, "\n") {
|
||||
if m := marker.FindStringSubmatch(strings.TrimRight(line, "\n")); m != nil {
|
||||
flush()
|
||||
cur = &Piece{Module: m[1]}
|
||||
continue
|
||||
}
|
||||
if cur != nil {
|
||||
b.WriteString(line)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// Hooks is every moment's pieces on this machine, read from the files the controller placed.
|
||||
func (m *Machine) Hooks() map[string]any {
|
||||
moments := map[string]any{}
|
||||
total := 0
|
||||
for _, moment := range Moments {
|
||||
p := MomentsDir + "/" + moment
|
||||
if !m.exists(p) {
|
||||
moments[moment] = map[string]any{"placed": false}
|
||||
continue
|
||||
}
|
||||
pieces := Pieces(m.read(p) + "\n")
|
||||
total += len(pieces)
|
||||
moments[moment] = map[string]any{"placed": true, "pieces": orEmpty(pieces)}
|
||||
}
|
||||
return map[string]any{"moments": moments, "pieces": total,
|
||||
"note": "each piece runs on its own with sh as root, bounded (MESH_POWER_BOUND, default 30 s); outcomes are in the journal under mesh-power"}
|
||||
}
|
||||
|
||||
func orEmpty[T any](s []T) []T {
|
||||
if s == nil {
|
||||
return []T{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/bin/sh
|
||||
# power-moment MOMENT [--force] — run every module's code for one power moment (novox/hq ADR 0211).
|
||||
# Shipped by the mesh's power module; edit the catalogue.
|
||||
#
|
||||
# MOMENT is after-boot, before-sleep, after-wake, before-shutdown, on-mains or on-battery, or
|
||||
# `supply`, which reads the machine's power source and runs on-mains or on-battery once per change.
|
||||
#
|
||||
# The moment's file holds what the controller placed: each module's code under a line `# <module>`.
|
||||
# Each module's piece runs on its own, with sh, as root, bounded by MESH_POWER_BOUND seconds
|
||||
# (default 30) and killed 5 s after that, so one module that hangs cannot hold the machine awake or
|
||||
# stop the next module. Every outcome goes to the journal under the tag mesh-power.
|
||||
set -u
|
||||
|
||||
moment=${1:-}
|
||||
force=${2:-}
|
||||
moments=/etc/mesh-power/moments
|
||||
bound=${MESH_POWER_BOUND:-30}
|
||||
runtime=/run/mesh-power
|
||||
|
||||
say() { logger -t mesh-power -- "$*" 2>/dev/null || true; printf '%s\n' "$*"; }
|
||||
|
||||
# The power source as the kernel says it: on mains when any Mains supply is online. A machine with
|
||||
# no Mains supply (a desktop, a server, a USB-C source only) has no source to change.
|
||||
source_now() {
|
||||
found=no
|
||||
for s in /sys/class/power_supply/*; do
|
||||
[ "$(cat "$s/type" 2>/dev/null)" = Mains ] || continue
|
||||
found=yes
|
||||
[ "$(cat "$s/online" 2>/dev/null)" = 1 ] && { echo on-mains; return; }
|
||||
done
|
||||
[ "$found" = yes ] && echo on-battery || echo none
|
||||
}
|
||||
|
||||
case "$moment" in
|
||||
supply)
|
||||
now=$(source_now)
|
||||
[ "$now" = none ] && exit 0
|
||||
mkdir -p "$runtime"
|
||||
last=$(cat "$runtime/source" 2>/dev/null || true)
|
||||
[ "$now" = "$last" ] && [ "$force" != --force ] && exit 0
|
||||
echo "$now" >"$runtime/source"
|
||||
moment=$now
|
||||
;;
|
||||
after-boot | before-sleep | after-wake | on-mains | on-battery) ;;
|
||||
before-shutdown)
|
||||
# Run only while the machine is really going down. The unit that runs it is stopped at shutdown,
|
||||
# and also when the module is taken off the machine; the second must not run anyone's shutdown code.
|
||||
state=$(systemctl is-system-running 2>/dev/null || true)
|
||||
if [ "$state" != stopping ] && [ "$force" != --force ]; then
|
||||
say "moment=before-shutdown skipped: the system is $state, not stopping"
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "power-moment: no moment $moment (after-boot, before-sleep, after-wake, before-shutdown, on-mains, on-battery, supply)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
file="$moments/$moment"
|
||||
[ -r "$file" ] || { say "moment=$moment: nothing placed"; exit 0; }
|
||||
|
||||
work=$(mktemp -d) || exit 1
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
# One file per module piece, in the order the controller placed them. A line that is exactly
|
||||
# "# <module name>" opens a piece; lines before the first are the holder's own header.
|
||||
awk -v dir="$work" '
|
||||
/^# [a-z0-9][a-z0-9-]*$/ { n++; name = substr($0, 3); out = sprintf("%s/%03d-%s", dir, n, name); next }
|
||||
n > 0 { print > out }
|
||||
' "$file"
|
||||
|
||||
ran=0 failed=0
|
||||
for piece in "$work"/*; do
|
||||
[ -e "$piece" ] || continue
|
||||
module=${piece##*/}
|
||||
module=${module#*-}
|
||||
start=$(date +%s)
|
||||
timeout --kill-after=5 "$bound" sh "$piece" </dev/null >"$piece.out" 2>&1
|
||||
rc=$?
|
||||
took=$(($(date +%s) - start))
|
||||
ran=$((ran + 1))
|
||||
case $rc in
|
||||
0) say "moment=$moment module=$module ok in ${took}s" ;;
|
||||
124 | 137) failed=$((failed + 1)); say "moment=$moment module=$module ended after ${bound}s: it did not finish" ;;
|
||||
*) failed=$((failed + 1)); say "moment=$moment module=$module exited $rc: $(tail -n 3 "$piece.out" | tr '\n' ' ')" ;;
|
||||
esac
|
||||
done
|
||||
say "moment=$moment: $ran piece(s), $failed failed"
|
||||
exit 0
|
||||
@@ -0,0 +1,8 @@
|
||||
module power
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
github.com/godbus/dbus/v5 v5.1.0
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user