WIP: power module (in progress)
This commit is contained in:
@@ -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,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,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
|
||||
}
|
||||
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 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
)
|
||||
@@ -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=
|
||||
Reference in New Issue
Block a user