diff --git a/checks/paths/go.mod b/checks/paths/go.mod new file mode 100644 index 00000000..bb495055 --- /dev/null +++ b/checks/paths/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/paths + +go 1.22 diff --git a/checks/paths/known b/checks/paths/known new file mode 100644 index 00000000..31835293 --- /dev/null +++ b/checks/paths/known @@ -0,0 +1,6 @@ +# Findings standing when the check took /etc and /usr/local/lib in (2026-10-08). Each is a module reading +# another's file instead of asking over the bus. The list only shrinks: a finding that no longer occurs +# must leave it. Format: # why it is still here +asus-zephyrus-g14 /etc/mesh-power modules/asus-zephyrus-g14/cmd/zephyrus/check.go # its check reads the power module's moment file; should ask power_hooks +asus-zephyrus-g14 /etc/mesh-power/moments modules/asus-zephyrus-g14/cmd/zephyrus/check.go # the same +asus-zephyrus-g14 /etc/mesh-power/moments/after-wake modules/asus-zephyrus-g14/cmd/zephyrus/check.go # the same diff --git a/checks/paths/main.go b/checks/paths/main.go new file mode 100644 index 00000000..122b7aa2 --- /dev/null +++ b/checks/paths/main.go @@ -0,0 +1,278 @@ +// Command paths holds the catalogue's modules to talking over the bus (novox/hq ADR 0260): no module +// names a path that another module's manifest places under /run, /var/lib, /usr/local/lib or /etc. +// Such a path is that module's state or configuration on its machine, and a second module reading or +// writing it is two modules talking through a file, which the bus exists to replace. +// +// A module owns every `path` of its resources under those roots. Every other module's manifest, and +// every file of its own but its documentation, its tests and its binaries, is searched for each as a +// whole path: followed by nothing, a slash, or a character no path takes. A path a container sees +// inside itself (the target of a `volumes` entry) is the container's, and is not searched. +// +// **A drop-in directory is no module's.** A directory whose name ends in `.d` (`/etc/sysctl.d`, +// `/etc/systemd/system/.d`, `/etc/X11/xorg.conf.d`) is a place several modules each put their own +// file, so naming it is not reaching into one module's state; the files in it are each their placer's. +// +// Two lists beside this file narrow it, each line a path or a finding and its reason after a `#`: +// +// - shared: paths every program on a machine reads as the machine's, not as a message from their +// owner — /etc/hosts, which every resolver reads. +// - known: the findings standing on the day the roots were widened (2026-10-08). The check fails on a +// finding not listed, and on a listed one that no longer occurs, so the list only shrinks. +// +// **Neither list may grow unseen.** With `-base ` holding the base branch's copies of both, a line +// the change adds is refused unless its reason says `allow:` and why — so an addition is a decision a +// reviewer reads in the diff, not a way past the check. +// +// **What it cannot see:** a path built in code from pieces (`filepath.Join("/etc", name)`), one read +// from the environment or a setting, and one reached through a shell the module runs. It reads text, +// not what a program does; review and the runtime's grants cover the rest. +// +// go run . +// +// Exits 1 on a finding. +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// stateRoots are where a machine keeps a module's state and configuration. +var stateRoots = []string{"/run/", "/var/lib/", "/usr/local/lib/", "/etc/"} + +// Finding is one module naming another's path. +type Finding struct { + Module, File, Path, Owner string +} + +func (f Finding) String() string { + return fmt.Sprintf("%s names %s in %s, and the path is %s's; modules talk over the bus (novox/hq ADR 0260)", + f.Module, f.Path, f.File, f.Owner) +} + +// Key is how a finding is listed in `known`: the module, the path and the file. +func (f Finding) Key() string { return f.Module + " " + f.Path + " " + f.File } + +// containerTargets drops what a container sees inside itself from a manifest before it is searched: +// the target half of every `volumes` entry of a container resource. +func containerTargets(m map[string]any) { + resources, _ := m["resources"].([]any) + for _, raw := range resources { + r, ok := raw.(map[string]any) + if !ok || r["type"] != "container" { + continue + } + volumes, _ := r["volumes"].([]any) + for i, v := range volumes { + if s, ok := v.(string); ok { + if src, _, two := strings.Cut(s, ":"); two { + volumes[i] = src + } + } + if o, ok := v.(map[string]any); ok { + delete(o, "target") + delete(o, "container") + } + } + } +} + +// Check is every finding in the catalogue at root, before the lists narrow it. +func Check(root string) ([]Finding, error) { + files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return nil, err + } + owner := map[string]string{} + modules := map[string]string{} + for _, f := range files { + raw, err := os.ReadFile(f) + if err != nil { + return nil, err + } + var m struct { + Module string `json:"module"` + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("%s: %w", f, err) + } + modules[m.Module] = filepath.Dir(f) + for _, r := range m.Resources { + p, _ := r["path"].(string) + if r["type"] == "directory" && strings.HasSuffix(filepath.Base(p), ".d") { + continue // a drop-in directory: several modules' place, no one module's + } + for _, root := range stateRoots { + if strings.HasPrefix(p, root) && len(p) > len(root) { + owner[strings.TrimRight(p, "/")] = m.Module + } + } + } + } + paths := make([]string, 0, len(owner)) + for p := range owner { + paths = append(paths, p) + } + sort.Strings(paths) + patterns := map[string]*regexp.Regexp{} + for _, p := range paths { + patterns[p] = regexp.MustCompile(regexp.QuoteMeta(p) + `(?:$|[^A-Za-z0-9._-])`) + } + names := make([]string, 0, len(modules)) + for n := range modules { + names = append(names, n) + } + sort.Strings(names) + var out []Finding + for _, module := range names { + err := filepath.WalkDir(modules[module], func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + name := d.Name() + if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, "_test.go") || + name == "go.sum" || name == "package-lock.json" { + return nil + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + if bytes.IndexByte(raw, 0) >= 0 { + return nil // a binary: what it says is in its source + } + if name == "module.json" { + var m map[string]any + if json.Unmarshal(raw, &m) == nil { + containerTargets(m) + raw, _ = json.Marshal(m) + } + } + text := string(raw) + for _, p := range paths { + if owner[p] != module && patterns[p].MatchString(text) { + rel, _ := filepath.Rel(root, path) + out = append(out, Finding{Module: module, File: rel, Path: p, Owner: owner[p]}) + } + } + return nil + }) + if err != nil { + return nil, err + } + } + return out, nil +} + +// list reads one of the lists beside this check: each line's entry before a `#`, trimmed, with its +// reason after it. +func list(path string) (map[string]bool, map[string]string, error) { + out, why := map[string]bool{}, map[string]string{} + f, err := os.Open(path) + if err != nil { + return nil, nil, err + } + defer f.Close() + s := bufio.NewScanner(f) + for s.Scan() { + line, reason, _ := strings.Cut(s.Text(), "#") + if line = strings.TrimSpace(line); line != "" { + out[line] = true + why[line] = strings.TrimSpace(reason) + } + } + return out, why, s.Err() +} + +// Grown is every entry a list has that its base copy does not, and whose reason does not say `allow:`. +func Grown(name string, now map[string]bool, why map[string]string, base map[string]bool) []string { + var out []string + for entry := range now { + if !base[entry] && !strings.Contains(why[entry], "allow:") { + out = append(out, fmt.Sprintf("checks/paths/%s gains %q, which the base branch's copy does not hold; "+ + "say why after `# allow:` on its line, or keep it out", name, entry)) + } + } + sort.Strings(out) + return out +} + +// Judge narrows the findings by the lists: what is left to refuse, and the known findings that no +// longer occur, which must leave the list. +func Judge(found []Finding, shared, known map[string]bool) (refused []Finding, gone []string) { + seen := map[string]bool{} + for _, f := range found { + if shared[f.Path] { + continue + } + seen[f.Key()] = true + if !known[f.Key()] { + refused = append(refused, f) + } + } + for k := range known { + if !seen[k] { + gone = append(gone, k) + } + } + sort.Strings(gone) + return refused, gone +} + +func main() { + root, baseDir := ".", "" + args := os.Args[1:] + if len(args) >= 2 && args[0] == "-base" { + baseDir, args = args[1], args[2:] + } + if len(args) > 0 { + root = args[0] + } + found, err := Check(root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + here := filepath.Join(root, "checks", "paths") + shared, sharedWhy, err1 := list(filepath.Join(here, "shared")) + known, knownWhy, err2 := list(filepath.Join(here, "known")) + if err1 != nil || err2 != nil { + fmt.Fprintln(os.Stderr, "the lists beside the check cannot be read:", err1, err2) + os.Exit(2) + } + var grown []string + if baseDir == "" { + fmt.Println("NOT COMPARED: no base branch's lists were given, so additions to them were not judged") + } else { + baseShared, _, err1 := list(filepath.Join(baseDir, "shared")) + baseKnown, _, err2 := list(filepath.Join(baseDir, "known")) + if err1 != nil || err2 != nil { + fmt.Fprintln(os.Stderr, "the base branch's lists cannot be read:", err1, err2) + os.Exit(2) + } + grown = append(Grown("shared", shared, sharedWhy, baseShared), Grown("known", known, knownWhy, baseKnown)...) + } + refused, gone := Judge(found, shared, known) + for _, g := range grown { + fmt.Println(g) + } + for _, f := range refused { + fmt.Println(f) + } + for _, k := range gone { + fmt.Printf("%q is in checks/paths/known and no longer occurs: take it out, so it cannot come back\n", k) + } + if len(refused) > 0 || len(gone) > 0 || len(grown) > 0 { + os.Exit(1) + } + fmt.Printf("paths: no module names another's state on its machine (%d known, listed to go)\n", len(known)) +} diff --git a/checks/paths/main_test.go b/checks/paths/main_test.go new file mode 100644 index 00000000..cf656593 --- /dev/null +++ b/checks/paths/main_test.go @@ -0,0 +1,135 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func catalogue(t *testing.T, files map[string]string) string { + t.Helper() + root := t.TempDir() + for p, c := range files { + full := filepath.Join(root, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(c), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +const owner = `{"module":"power","resources":[{"id":"d","type":"directory","path":"/run/mesh-power"}]}` + +func TestAModuleReadingAnothersStateIsFound(t *testing.T) { + root := catalogue(t, map[string]string{ + "modules/power/module.json": owner, + "modules/power/cmd/power/draw.go": `const f = "/run/mesh-power/draw"`, + "modules/bar/module.json": `{"module":"bar","resources":[{"id":"x","type":"file","path":"/etc/x","content":"cat /run/mesh-power/draw"}]}`, + "modules/other/module.json": `{"module":"other"}`, + "modules/other/files/bin/read": "#!/bin/sh\ncat /run/mesh-power\n", + "modules/other/README.md": "it once read /run/mesh-power/draw", + "modules/other/cmd/x/x_test.go": `"/run/mesh-power"`, + "modules/near/module.json": `{"module":"near","resources":[{"id":"y","type":"file","path":"/etc/y","content":"/run/mesh-power-other/x"}]}`, + }) + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + var got []string + for _, f := range found { + got = append(got, f.Module+" "+f.File) + } + want := []string{"bar modules/bar/module.json", "other modules/other/files/bin/read"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("found %v, want %v", got, want) + } + if !strings.Contains(found[0].String(), "power's") { + t.Fatalf("%s", found[0]) + } +} + +func TestACatalogueWhereEachKeepsToItsOwnIsClean(t *testing.T) { + root := catalogue(t, map[string]string{ + "modules/power/module.json": owner, + "modules/power/cmd/power/draw.go": `const f = "/run/mesh-power/draw"`, + }) + if found, err := Check(root); err != nil || len(found) != 0 { + t.Fatalf("%v %v", found, err) + } +} + +func TestEtcAndUsrLocalLibAreAModulesTooButNotInsideAContainer(t *testing.T) { + root := catalogue(t, map[string]string{ + "modules/power/module.json": `{"module":"power","resources":[{"id":"m","type":"directory","path":"/etc/mesh-power/moments"},` + + `{"id":"s","type":"archive","path":"/usr/local/lib/mesh-power"}]}`, + "modules/reader/module.json": `{"module":"reader"}`, + "modules/reader/cmd/r/r.go": `const a, b = "/etc/mesh-power/moments/after-wake", "/usr/local/lib/mesh-power/bin/x"`, + // A container that mounts something at that path inside itself names its own world. + "modules/boxed/module.json": `{"module":"boxed","resources":[{"id":"c","type":"container",` + + `"volumes":["/srv/boxed:/etc/mesh-power/moments",{"source":"/srv/b","target":"/usr/local/lib/mesh-power"}]}]}`, + // A binary says what its source says. + "modules/boxed/bin/tool": "\x00\x01/etc/mesh-power/moments\x00", + }) + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + var got []string + for _, f := range found { + got = append(got, f.Module+" "+f.Path) + } + want := []string{"reader /etc/mesh-power/moments", "reader /usr/local/lib/mesh-power"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("found %v, want %v", got, want) + } +} + +func TestSharedPathsPassAndKnownFindingsOnlyShrink(t *testing.T) { + found := []Finding{ + {Module: "dns", File: "modules/dns/module.json", Path: "/etc/hosts", Owner: "hostname"}, + {Module: "laptop", File: "modules/laptop/k.go", Path: "/etc/triggers", Owner: "keys"}, + {Module: "new", File: "modules/new/n.go", Path: "/run/mesh-power", Owner: "power"}, + } + shared := map[string]bool{"/etc/hosts": true} + known := map[string]bool{ + "laptop /etc/triggers modules/laptop/k.go": true, + "old /run/x modules/old/o.go": true, + } + refused, gone := Judge(found, shared, known) + if len(refused) != 1 || refused[0].Module != "new" { + t.Fatalf("refused %v", refused) + } + if len(gone) != 1 || gone[0] != "old /run/x modules/old/o.go" { + t.Fatalf("gone %v", gone) + } +} + +func TestADropInDirectoryIsNoModulesButItsFilesAre(t *testing.T) { + root := catalogue(t, map[string]string{ + "modules/memory/module.json": `{"module":"memory","resources":[{"id":"d","type":"directory","path":"/etc/sysctl.d"},` + + `{"id":"f","type":"file","path":"/etc/sysctl.d/90-memory.conf"}]}`, + "modules/other/module.json": `{"module":"other","resources":[{"id":"f","type":"file","path":"/etc/sysctl.d/80-other.conf"}]}`, + "modules/other/cmd/o/o.go": `const a, b = "/etc/sysctl.d", "/etc/sysctl.d/90-memory.conf"`, + }) + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + if len(found) != 1 || found[0].Path != "/etc/sysctl.d/90-memory.conf" || found[0].Module != "other" { + t.Fatalf("%v", found) + } +} + +func TestAListGrowsOnlyWithAReasonTheDiffShows(t *testing.T) { + base := map[string]bool{"/etc/hosts": true} + now := map[string]bool{"/etc/hosts": true, "/etc/a": true, "/etc/b": true} + why := map[string]string{"/etc/a": "because", "/etc/b": "allow: every program reads it"} + got := Grown("shared", now, why, base) + if len(got) != 1 || !strings.Contains(got[0], `"/etc/a"`) { + t.Fatalf("%v", got) + } +} diff --git a/checks/paths/shared b/checks/paths/shared new file mode 100644 index 00000000..e305bcff --- /dev/null +++ b/checks/paths/shared @@ -0,0 +1,3 @@ +# Paths every program on a machine reads as the machine's own, not as a message from the module that +# writes them (checks/paths, novox/hq ADR 0260). One per line, with why. +/etc/hosts # every resolver on the machine reads it; the hostname module writes it for all of them diff --git a/merge-check.sh b/merge-check.sh index 00d094d7..2b07c81f 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -19,9 +19,23 @@ # retired-words, in any text a module's tools can show an agent — every string in its own code that is # not a test, and every description in its manifest. Run over every module, not only the touched ones, # so a word newly retired is found everywhere it stands. The check's own test runs first. +# 4. the paths (novox/hq ADR 0260): no module names a path under /run, /var/lib, /usr/local/lib or /etc +# that another module's manifest places — modules talk over the bus, not through each other's files. +# Over every module; checks/paths/shared and checks/paths/known narrow it, and known only shrinks. set -eu (cd checks/words && go test -count=1 . && go run . ../..) +# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are +# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in. +base_lists="" +base_ref="origin/${MESH_CHECK_BASE:-main}" +if git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null 2>&1 && + git cat-file -e "$base_ref:checks/paths/known" 2>/dev/null && git cat-file -e "$base_ref:checks/paths/shared" 2>/dev/null; then + base_lists=$(mktemp -d) + git show "$base_ref:checks/paths/known" >"$base_lists/known" + git show "$base_ref:checks/paths/shared" >"$base_lists/shared" +fi +(cd checks/paths && go test -count=1 . && if [ -n "$base_lists" ]; then go run . -base "$base_lists" ../..; else go run . ../..; fi) if [ -n "${MESH_GATE:-}" ]; then checked=$("$MESH_GATE" module check modules/*/module.json) || { printf '%s\n' "$checked"; exit 1; } diff --git a/modules/asus-zephyrus-g14/README.md b/modules/asus-zephyrus-g14/README.md index 2d6f69ba..05efa63c 100644 --- a/modules/asus-zephyrus-g14/README.md +++ b/modules/asus-zephyrus-g14/README.md @@ -58,9 +58,11 @@ daemons and probably the same keys. Their GPU options are unverified. handling. Until then it stays as found, and it still calls the predecessor's `as-user`. - **The screenshot script** (`~/.config/i3/scripts/screenshot.sh`) is any machine's. The `i3` module binds it too. This module only binds the key the firmware sends for it. -- **The bar's battery block.** It belongs here (a block that follows this model's hardware), but the - bar has no way in yet (`i3status-rust` README). Once ADR 0210's contributions reach the bar seat, - this module contributes it. +- **The bar's battery block, and the power draw's.** A battery is any laptop's, not this model's, so + the `power` module contributes both blocks wherever the machine reports a battery or a power meter + (novox/hq ADR 0255). This module would contribute only what is this model's alone, such as the charge + limit asusd holds; nothing yet. The predecessor's AMD GPU block is no model's either, and waits for a + generic module (the `i3status-rust` README). **Requires `x11-display`.** The i3 lines and the session scripts need a display, so the module is assigned where the display server is. @@ -306,7 +308,7 @@ The predecessor carried this model in its desktop module's `g14` and `laptop` fl | `g14-90-backlight.rules` | **this module**, the same path | | `g14-logind-brightness.conf` | **retired**: an unknown logind key that did nothing | | `laptop-monitor-wizard.sh`, `laptop-91-monitor-hotplug.rules` | **any laptop's, not this module's**: kept as found, for the `xorg` module's autorandr to replace | -| `bottom-bar.g14.toml` (the battery block) | **waits**: this module's, once the bar takes contributions (ADR 0210) | +| `bottom-bar.g14.toml` (the battery block) | **the `power` module's**: any machine with a battery gets it (ADR 0255) | | `razer-basilisk-battery-percentage` | **not this model's**: a mouse. No module carries it (`i3status-rust` README) | | `screenshot.sh` | **not this model's**: any machine's script, which the `i3` module binds too; this module binds Fn+F6 to it | | package `triggerhappy` | **depended on, kept as found** (outside the distribution, above) | diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go b/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go index 7e986c6c..410c78ac 100644 --- a/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go @@ -113,6 +113,12 @@ func TestTheVendorKeysRunTheModulesOwnScriptsAndTheyParse(t *testing.T) { if len(scripts) == 0 { t.Fatal("no scripts") } + // The scripts are bash's. The build seat's Go toolchain carries no bash, so there they are said as + // not parsed rather than failed: a missing interpreter is not a broken script. + if _, err := exec.LookPath("bash"); err != nil { + t.Logf("NOT PARSED: no bash on this machine, so the scripts' syntax was not checked") + return + } for _, s := range scripts { if out, err := exec.Command("bash", "-n", s).CombinedOutput(); err != nil { t.Errorf("%s: %v %s", s, err, out) diff --git a/modules/i3status-rust/README.md b/modules/i3status-rust/README.md index 70a19c75..f8e94ce0 100644 --- a/modules/i3status-rust/README.md +++ b/modules/i3status-rust/README.md @@ -3,7 +3,8 @@ The bars as a module (novox/hq ADR 0208, research 026/05). - Installs `i3status-rust`. The update block's `checkupdates` comes from `pacman-contrib`, which the `pacman` module installs on every node; two modules declaring one package is refused at composition. - Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2). Requires `x11-display` on its own + Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2) and renders the blocks other modules + contribute to it (ADR 0255, below). Requires `x11-display` on its own machine: its bars are i3bar's. - Owns `~/.config/i3status-rust/`, both bars (`top-bar.toml`, `bottom-bar.toml`) and their icon set (`icons/custom-icons.toml`). @@ -25,25 +26,42 @@ The bars as a module (novox/hq ADR 0208, research 026/05). | `i3status_rust_block_run` | one block alone, with the bar's theme and icons, to see what it shows or why it errors | | `i3status_rust_themes` | the themes on the machine and the one each bar uses | -## The battery, and what else was left out +## Blocks other modules contribute (novox/hq ADR 0255) -A bar block that follows one machine's hardware is that machine model's hardware module's (ADR 0208 -§1), so the bottom bar here has none: +A block that depends on the machine's hardware is not written here. The module that knows the hardware +contributes it to `node-bar` as data that says what it shows, and this module renders it: -- **the battery,** which only the laptop has; -- **the GPU,** which was the laptop's AMD block, commented out for NVIDIA on the desktop; -- **three Bluetooth headsets** by hardware address: one person's devices, not the bar's. +- **What a contribution says:** which bar (`bottom`, `top`), which place (`resources`, beside the + processor, memory and disks; `status`, beside the sound), an order from 0 to 99, what it shows + (`battery`, `command`) and that kind's options. Nothing in it is i3status-rust's. +- **How it is rendered:** the claim's `renders.block` is a template (its source is `files/block.tmpl`) + that turns one piece into a `[[block]]`. The controller runs it and places the result where each bar + says `${contribution:node-bar:block:bar=…,place=…}`. Each bar places both of its places once; the + catalogue check refuses a holder that misses one. +- **Where it applies:** a contribution may name a capability the machine must report. The `power` + module's battery block names `battery` and its power-draw block names `power-meter`, so the laptop's + bar gets both and a desktop only the power draw. +- **A value from the bus:** a block that `shows: state` names another module's state, such as + `power.draw`. The mesh grants this module the read, because a module on its machine offers it; the + manifest names no other module. This module's bundle watches it and leaves the value's text in the + account's runtime directory (`i3status-rust/values/`), dated with the value's own `at`, and the block + runs `~/.local/bin/i3status-value`, which prints it, or nothing when it was measured more than 30 s + ago, so the block hides a value frozen at its source. A watch that says nothing for a minute is + looked at once: a key that is gone has expired at its source and shows nothing; a key still there + means the watch died, so it is watched again and that is logged once. A refused watch is logged and + tried again, and a value no bar shows any more is no longer kept. The bus grants this module the one key each block shows (the machine's own unless + the block names another, `[a-z0-9-]`). No file of another module is read: modules talk over the bus. +- **The battery** is i3status-rust's own battery block, which reads the kernel's power-supply class + itself: the bar reading the machine, not another module. -i3status-rust reads no drop-in directory, and ADR 0208's slots cover `xinitrc` and `xresources` only, -so a hardware module has no way into this file yet. Two ways forward, for the operator to choose: +Still left out: -1. **Give the bar a slot.** A `shell`-style contribution `for: i3status-rust` would let the laptop's - hardware module add its battery block. That is a decision record of its own, extending ADR 0208 §4. -2. **Blocks that show only where they apply.** i3status-rust's common `if_command` option (for - example `test -d /sys/class/power_supply/BAT0`) hides a block on a machine without the hardware. - That is "say it by what is there" (ADR 0112), but the knowledge stays in the bar. +- **The GPU's load,** the laptop's AMD block (commented out for NVIDIA on the desktop). A generic GPU + block belongs to a generic module, which does not exist yet; a GPU's power is in the power draw. +- **Three Bluetooth headsets** by hardware address: one person's devices, not the bar's. -Until one of them is chosen, **the laptop's bar shows no battery** once this module is assigned. +The second way the README once offered, blocks with i3status-rust's `if_command` that hide themselves on +a machine without the hardware, was rejected: the knowledge would stay in the bar, decided at run time. ## What it improves on what was found @@ -53,6 +71,9 @@ Until one of them is chosen, **the laptop's bar shows no battery** once this mod - **The update count is the module's own,** adopted from the operator's `~/scripts/pkg-updates`. It counts AUR updates only where an AUR helper is installed. Clicking it lists the updates in the launcher's menu, instead of a theme from a cloned theme repository. +- **The memory is what programs hold** (`$mem_used`), not `$mem_total_used`, which counts the cache the + kernel gives back when asked and read 96 % on a machine at 73 %. It is the figure the + `memory-pressure` module judges by (changed 2026-10-08). - **The faces** are JetBrains Mono Nerd Font, not Hack. - **The watchdog is no longer a hand-enabled user unit** that ran on one workstation and not the other. It runs on both, once per session, and ends with the session. @@ -79,7 +100,6 @@ Until one of them is chosen, **the laptop's bar shows no battery** once this mod - `node-bar`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them, `mctl` reads them as unknown. -- The battery block waits for one of the two ways above. - The watchdog would be a user unit once user-scoped units ship (mesh-host #72). It is not, because it runs per session and ends with the session, as a session-start line already does. - `pacman-contrib` is the `pacman` module's, which took it over as foreseen: a package is declared diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go new file mode 100644 index 00000000..8314ce40 --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "text/template" +) + +// i3status-rust renders the blocks other modules contribute (novox/hq ADR 0255): a bar-neutral piece +// — what it shows, on which bar, where — in its own grammar, through the template its claim gives. The +// controller runs the template; these tests run it the same way, with the one function it is given. + +func blockTemplate(t *testing.T) *template.Template { + t.Helper() + m := readManifest(t) + src, err := os.ReadFile("../../files/block.tmpl") + if err != nil { + t.Fatal(err) + } + if m.Claims[0].Renders["block"] != string(src) { + t.Fatal("the claim's template is not files/block.tmpl") + } + quote := func(v any) (string, error) { + b, err := json.Marshal(fmt.Sprint(v)) + return string(b), err + } + return template.Must(template.New("block").Funcs(template.FuncMap{"quote": quote}).Parse(string(src))) +} + +func render(t *testing.T, tmpl *template.Template, piece string) string { + t.Helper() + view := map[string]any{"module": "power", "machine": "laptop"} + if err := json.Unmarshal([]byte(piece), &view); err != nil { + t.Fatal(err) + } + var b bytes.Buffer + if err := tmpl.Execute(&b, view); err != nil { + t.Fatal(err) + } + if strings.Contains(b.String(), "") { + t.Fatalf("%s rendered a missing value: %q", piece, b.String()) + } + return b.String() +} + +func TestAContributedBatteryIsI3statusRustsBatteryBlock(t *testing.T) { + tmpl := blockTemplate(t) + got := render(t, tmpl, `{"bar":"bottom","place":"status","shows":"battery"}`) + if !strings.HasPrefix(got, "[[block]]\nblock = \"battery\"\nformat = ") || strings.Contains(got, "device") || + !strings.Contains(got, `missing_format = ""`) || !strings.HasSuffix(got, "\n\n") { + t.Fatalf("%q", got) + } + got = render(t, tmpl, `{"bar":"bottom","place":"status","shows":"battery","options":{"device":"BAT1"}}`) + if !strings.Contains(got, "block = \"battery\"\ndevice = \"BAT1\"\nformat = ") { + t.Fatalf("%q", got) + } +} + +func TestAContributedCommandIsACustomBlockQuoted(t *testing.T) { + got := render(t, blockTemplate(t), + `{"bar":"bottom","place":"status","shows":"command","options":{"command":"say \"hi\"","interval":5}}`) + want := "[[block]]\nblock = \"custom\"\ncommand = \"say \\\"hi\\\"\"\ninterval = 5\n\n" + if got != want { + t.Fatalf("%q, want %q", got, want) + } +} + +func TestEachBarPlacesBothPlacesOnce(t *testing.T) { + m := readManifest(t) + for _, bar := range []string{"bottom", "top"} { + content := m.resource(t, bar+"-bar")["content"].(string) + for _, place := range []string{"resources", "status"} { + ph := "${contribution:node-bar:block:bar=" + bar + ",place=" + place + "}" + if strings.Count(content, ph) != 1 { + t.Errorf("the %s bar places %s %d times", bar, ph, strings.Count(content, ph)) + } + } + } + bottom := m.resource(t, "bottom-bar")["content"].(string) + resources := strings.Index(bottom, "place=resources") + if !(strings.Index(bottom, `block = "memory"`) < resources && resources < strings.Index(bottom, `block = "docker"`)) || + !(strings.Index(bottom, `block = "sound"`) < strings.Index(bottom, "place=status") && + strings.Index(bottom, "place=status") < strings.Index(bottom, `block = "weather"`)) { + t.Fatal("the places are not beside the memory and beside the sound") + } +} + +func TestTheMemoryIsWhatProgramsHoldNotTheCache(t *testing.T) { + bottom := readManifest(t).resource(t, "bottom-bar")["content"].(string) + if !strings.Contains(bottom, `format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"`) || + strings.Contains(bottom, "mem_total_used") { + t.Fatal("the memory block counts the cache as used") + } +} + +func TestAContributedStateIsACustomBlockReadingThisMachinesValue(t *testing.T) { + tmpl := blockTemplate(t) + got := render(t, tmpl, `{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw"}}`) + want := "[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-value power.draw laptop\"\ninterval = 5\nhide_when_empty = true\n\n" + if got != want { + t.Fatalf("%q, want %q", got, want) + } + got = render(t, tmpl, `{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw","key":"desk"}}`) + if !strings.Contains(got, "i3status-value power.draw desk") { + t.Fatalf("%q", got) + } + // And the bundle finds what it rendered. + f := filepath.Join(t.TempDir(), "bottom-bar.toml") + if err := os.WriteFile(f, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + if w := wantedIn([]string{f}); len(w) != 1 || w[0] != (Wanted{"power.draw", "desk"}) { + t.Fatalf("%v", w) + } +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/main.go b/modules/i3status-rust/cmd/i3status-rust-tools/main.go index 90918c19..ca8242f6 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/main.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/main.go @@ -11,6 +11,8 @@ import ( ) func main() { + // The values other modules keep on the bus for their blocks (novox/hq ADR 0260). + go FollowValues() if err := stdio.Serve("", tools()); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go index 441415f9..5c76cde9 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go @@ -33,6 +33,8 @@ type claim struct { Name string `json:"name"` Scope string `json:"scope"` Serves []string `json:"serves"` + // How the holder renders each kind its seat receives as data (novox/hq ADR 0255). + Renders map[string]string `json:"renders"` } type environment struct { diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go index 0acfefa2..ec41d8db 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go @@ -16,7 +16,8 @@ func TestItClaimsTheBarSeatAndRequiresTheXDisplay(t *testing.T) { if m.Module != "i3status-rust" || m.Seats != nil { t.Fatalf("module %q declares seats %v", m.Module, m.Seats) } - if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-bar", Scope: "node"}}) { + if len(m.Claims) != 1 || m.Claims[0].Name != "node-bar" || m.Claims[0].Scope != "node" || + len(m.Claims[0].Renders) != 1 || m.Claims[0].Renders["block"] == "" { t.Fatalf("claims: %+v", m.Claims) } if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) { @@ -32,6 +33,7 @@ func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) { for id, src := range map[string]string{ "top-bar": "files/top-bar.toml", "bottom-bar": "files/bottom-bar.toml", "icons": "files/icons/custom-icons.toml", "updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog", + "value": "files/bin/i3status-value", } { m.sameAsSource(t, id, src) } diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/values.go b/modules/i3status-rust/cmd/i3status-rust-tools/values.go new file mode 100644 index 00000000..3e38c2c8 --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/values.go @@ -0,0 +1,294 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "sync" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// The values other modules keep on the bus for a block they contributed (novox/hq ADR 0260). +// +// A block that `shows` a state is rendered as a custom block running `i3status-value `. +// i3status-rs cannot reach the bus, and this bundle can: the node's runtime launched it, and the mesh +// granted this module a read of the one key each such block shows. So it follows each state its bars +// name and leaves the value's text in this module's own directory, where the block's command reads it. +// No file of another module is read, and nothing but the bus passes between them. +// +// **The value's age is its own.** The file is dated with the value's `at` when it has one, so the +// block hides a value that stopped changing at its source, not only one that stopped arriving here. + +// FollowEvery is how often the bars are read again and every followed value looked at; Silent is how +// long a watch may say nothing before the value is read again — twice the power draw's lifetime. +var ( + FollowEvery = 30 * time.Second + Silent = 60 * time.Second +) + +// valueCall is how a bar names a value: the command the template renders. A key is a machine's name. +var valueCall = regexp.MustCompile(`i3status-value ([a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*) ([a-z0-9][a-z0-9-]*)`) + +// Wanted is one state and key a bar shows. +type Wanted struct{ State, Key string } + +func (w Wanted) file() string { return w.State + "@" + w.Key } + +// wantedIn is every value the bars' files name, each once, in order. +func wantedIn(files []string) []Wanted { + seen := map[Wanted]bool{} + for _, f := range files { + raw, err := os.ReadFile(f) + if err != nil { + continue + } + for _, m := range valueCall.FindAllStringSubmatch(string(raw), -1) { + seen[Wanted{State: m[1], Key: m[2]}] = true + } + } + out := make([]Wanted, 0, len(seen)) + for w := range seen { + out = append(out, w) + } + sort.Slice(out, func(i, j int) bool { return out[i].file() < out[j].file() }) + return out +} + +// valuesDir is where the values are left: the account's runtime directory, gone at logout. +func valuesDir() string { + return filepath.Join(runUserDir, strconv.Itoa(os.Getuid()), "i3status-rust", "values") +} + +// keep writes one value to its file: its `text`, dated with its `at` when it has one; no file once the +// value is deleted. Each write goes through a temporary file of its own and is renamed into place, so a +// watch's write and a reread's never meet halfway. +func keep(dir string, w Wanted, op string, value json.RawMessage) error { + path := filepath.Join(dir, w.file()) + if op == "delete" || op == "purge" || value == nil { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + var v struct { + Text string `json:"text"` + At string `json:"at"` + } + if err := json.Unmarshal(value, &v); err != nil { + return fmt.Errorf("%s's value for %s has no text: %w", w.State, w.Key, err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, "."+w.file()+".*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) // gone after the rename; left behind only on a failure + if _, err := tmp.WriteString(v.Text + "\n"); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if at, err := time.Parse(time.RFC3339, v.At); err == nil { + if err := os.Chtimes(tmp.Name(), at, at); err != nil { + return err + } + } + return os.Rename(tmp.Name(), path) +} + +// Bus is what following needs of the bus: a watch of one key, and a read of it. The SDK's in the +// bundle, a fake in a test. +type Bus struct { + Watch func(state, key string, handle func(op string, value json.RawMessage) error) error + Get func(state, key string) (json.RawMessage, bool, error) +} + +// following is one value followed. +type following struct { + // last is when its watch last handed anything over, or when it was last started or found expired. + last time.Time + // gen is which watch is the current one: a watch started again leaves the old one running (the SDK + // cannot end it), and what an old one hands over is dropped. + gen int + // stopped is a value no bar shows any more. + stopped bool + // dead is a watch found silent while its value went on, said once until a watch speaks again. + dead bool + // expired is a value its source stopped giving: the key is gone and its watch has nothing to say. + expired bool +} + +// Follower follows the values the bars name. +type Follower struct { + Dir string + Bus Bus + Now func() time.Time + Say func(string) + + mu sync.Mutex + of map[Wanted]*following +} + +// Round reads the bars once and brings what is followed into line with them: +// - a value a bar names and nothing follows is watched; a watch the runtime refuses — a read not +// granted yet — is said and tried again next round; +// - a value whose watch has said nothing for Silent is read once. A key that is gone has expired at +// its source (its lifetime ends without an event), and is left until the watch speaks or another +// Silent has passed. A key still there means the watch missed it: the value is kept, the watch is +// started again, and that is said once — not every round — until a watch speaks again; +// - a value no bar names any more is no longer kept: its file goes, and what its watch hands over is +// dropped. The SDK has no way to end a watch; one that ends with the bundle is the price. +func (f *Follower) Round(files []string) { + f.mu.Lock() + if f.of == nil { + f.of = map[Wanted]*following{} + } + f.mu.Unlock() + wanted := map[Wanted]bool{} + for _, w := range wantedIn(files) { + wanted[w] = true + f.mu.Lock() + fw, known := f.of[w] + var stopped, silent bool + if known { + stopped = fw.stopped + silent = f.Now().Sub(fw.last) > Silent + } + f.mu.Unlock() + switch { + case !known: + f.start(w) + case stopped: + f.mu.Lock() + fw.stopped = false + f.mu.Unlock() + f.start(w) + case silent: + f.silent(w) + } + } + f.mu.Lock() + var gone []Wanted + for w, fw := range f.of { + if !wanted[w] && !fw.stopped { + fw.stopped = true + fw.gen++ + gone = append(gone, w) + } + } + f.mu.Unlock() + for _, w := range gone { + if err := keep(f.Dir, w, "delete", nil); err != nil { + f.Say(err.Error()) + } + f.Say(fmt.Sprintf("no bar shows %s %s any more; it is no longer kept", w.State, w.Key)) + } +} + +// start watches a value, as a new generation. Its entry is there before the watch begins, because the +// SDK hands every current value over before Watch returns, and a value handed to nobody is lost. +func (f *Follower) start(w Wanted) { + f.mu.Lock() + fw, existed := f.of[w] + if !existed { + fw = &following{} + f.of[w] = fw + } + fw.gen++ + gen := fw.gen + fw.last = f.Now() + f.mu.Unlock() + err := f.Bus.Watch(w.State, w.Key, func(op string, value json.RawMessage) error { + f.mu.Lock() + current := f.of[w] == fw && fw.gen == gen && !fw.stopped + if current { + fw.last, fw.dead, fw.expired = f.Now(), false, false + } + f.mu.Unlock() + if !current { + return nil + } + return keep(f.Dir, w, op, value) + }) + if err != nil { + f.mu.Lock() + if !existed { + delete(f.of, w) + } else { + fw.gen++ // the refused watch is no generation; the next round tries again + } + f.mu.Unlock() + f.Say(fmt.Sprintf("cannot watch %s %s: %v; trying again in %s", w.State, w.Key, err, FollowEvery)) + } +} + +// silent looks once at a value whose watch has said nothing for Silent. +func (f *Follower) silent(w Wanted) { + value, found, err := f.Bus.Get(w.State, w.Key) + f.mu.Lock() + fw := f.of[w] + fw.last = f.Now() + wasDead, wasExpired := fw.dead, fw.expired + f.mu.Unlock() + switch { + case err != nil: + f.Say(fmt.Sprintf("cannot read %s %s: %v", w.State, w.Key, err)) + case !found: + // Expired at its source: nothing to show, and nothing wrong with the watch. + if err := keep(f.Dir, w, "delete", nil); err != nil { + f.Say(err.Error()) + } + f.mu.Lock() + fw.expired = true + f.mu.Unlock() + if !wasExpired { + f.Say(fmt.Sprintf("%s %s has expired at its source; showing nothing until it is given again", w.State, w.Key)) + } + default: + if err := keep(f.Dir, w, "put", value); err != nil { + f.Say(err.Error()) + } + if !wasDead { + f.Say(fmt.Sprintf("the watch of %s %s said nothing for %s while its value went on; watching it again", + w.State, w.Key, Silent)) + } + f.mu.Lock() + fw.dead = true + f.mu.Unlock() + f.start(w) + } +} + +// FollowValues runs a Round every FollowEvery for as long as the bundle runs, so a block a push adds +// is followed without a restart. +func FollowValues() { + f := &Follower{Dir: valuesDir(), Now: time.Now, + Say: func(s string) { fmt.Fprintln(os.Stderr, "i3status-rust:", s) }, + Bus: Bus{ + Watch: func(state, key string, handle func(string, json.RawMessage) error) error { + return stdio.State(state).Watch(key, func(c stdio.StateChange) error { return handle(c.Op, c.Value) }) + }, + Get: func(state, key string) (json.RawMessage, bool, error) { + e, err := stdio.State(state).Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil + }, + }} + for { + files, _ := filepath.Glob(filepath.Join(configDir(), "*.toml")) + f.Round(files) + time.Sleep(FollowEvery) + } +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go new file mode 100644 index 00000000..8a47e1fa --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go @@ -0,0 +1,252 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// Defends novox/hq ADR 0260: a value another module keeps reaches the bar over the bus, through this +// module's own bundle and directory; a dead watch is never silent; a removed block is no longer kept; +// and a value frozen at its source hides. + +func TestAValueIsKeptAsItsTextDatedByItsAtAndGoneWhenDeleted(t *testing.T) { + dir := t.TempDir() + w := Wanted{"power.draw", "laptop"} + at := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) + if err := keep(dir, w, "put", json.RawMessage(`{"text":"18.2 W machine","at":"2026-10-08T12:00:00Z"}`)); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "power.draw@laptop") + b, err := os.ReadFile(path) + info, _ := os.Stat(path) + if err != nil || string(b) != "18.2 W machine\n" || !info.ModTime().Equal(at) { + t.Fatalf("%q %v, dated %v", b, err, info.ModTime()) + } + if err := keep(dir, w, "delete", nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("a deleted value is still shown") + } + if err := keep(dir, w, "put", json.RawMessage(`"no text"`)); err == nil { + t.Fatal("a value with no text was kept") + } +} + +type fakeBus struct { + refuse bool + watched []Wanted + handles []func(string, json.RawMessage) error + current json.RawMessage // handed to a new watch before Watch returns, as the SDK does + gets int + value json.RawMessage + keyFound bool +} + +func (b *fakeBus) bus() Bus { + return Bus{ + Watch: func(state, key string, h func(string, json.RawMessage) error) error { + if b.refuse { + return errors.New("not granted") + } + b.watched = append(b.watched, Wanted{state, key}) + b.handles = append(b.handles, h) + if b.current != nil { + return h("put", b.current) + } + return nil + }, + Get: func(string, string) (json.RawMessage, bool, error) { b.gets++; return b.value, b.keyFound, nil }, + } +} + +func (b *fakeBus) latest() func(string, json.RawMessage) error { return b.handles[len(b.handles)-1] } + +func barFiles(t *testing.T, content string) []string { + t.Helper() + f := filepath.Join(t.TempDir(), "bottom-bar.toml") + if err := os.WriteFile(f, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return []string{f} +} + +type follow struct { + t *testing.T + now time.Time + said []string + bus *fakeBus + dir string + f *Follower + bar []string +} + +func newFollow(t *testing.T) *follow { + x := &follow{t: t, now: time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC), bus: &fakeBus{}, dir: t.TempDir()} + x.f = &Follower{Dir: x.dir, Bus: x.bus.bus(), Now: func() time.Time { return x.now }, Say: func(s string) { x.said = append(x.said, s) }} + x.bar = barFiles(t, `command = "~/.local/bin/i3status-value power.draw laptop"`) + return x +} + +func (x *follow) shown() string { + b, err := os.ReadFile(filepath.Join(x.dir, "power.draw@laptop")) + if err != nil { + return "" + } + return string(b) +} + +func TestTheValueAWatchHandsOverAtOnceIsKept(t *testing.T) { + x := newFollow(t) + x.bus.current = json.RawMessage(`{"text":"18.2 W machine"}`) + x.f.Round(x.bar) + if x.shown() != "18.2 W machine\n" { + t.Fatalf("the value handed over before Watch returned was dropped: %q", x.shown()) + } +} + +func TestARefusedWatchIsSaidAndTriedAgain(t *testing.T) { + x := newFollow(t) + x.bus.refuse = true + x.f.Round(x.bar) + if len(x.said) != 1 || !strings.Contains(x.said[0], "cannot watch power.draw laptop: not granted") { + t.Fatalf("%v", x.said) + } + x.bus.refuse = false + x.f.Round(x.bar) + if len(x.bus.watched) != 1 { + t.Fatalf("watched %v", x.bus.watched) + } +} + +func TestASilentWatchWhoseValueWentOnIsWatchedAgainAndSaidOnce(t *testing.T) { + x := newFollow(t) + x.f.Round(x.bar) + x.bus.value, x.bus.keyFound = json.RawMessage(`{"text":"9.0 W CPU"}`), true + for i := 0; i < 3; i++ { + x.now = x.now.Add(Silent + time.Second) + x.f.Round(x.bar) + } + if len(x.bus.watched) != 4 || x.shown() != "9.0 W CPU\n" { + t.Fatalf("watched %d times, shows %q", len(x.bus.watched), x.shown()) + } + dead := 0 + for _, s := range x.said { + if strings.Contains(s, "watching it again") { + dead++ + } + } + if dead != 1 { + t.Fatalf("a dead watch said %d times: %v", dead, x.said) + } + // What an old watch hands over is dropped; the current one's is kept. + if err := x.bus.handles[0]("put", json.RawMessage(`{"text":"old"}`)); err != nil || x.shown() != "9.0 W CPU\n" { + t.Fatalf("an old watch wrote: %q %v", x.shown(), err) + } + if err := x.bus.latest()("put", json.RawMessage(`{"text":"10.0 W CPU"}`)); err != nil || x.shown() != "10.0 W CPU\n" { + t.Fatalf("the current watch did not: %q %v", x.shown(), err) + } +} + +func TestAnExpiredKeyIsExpiredNotADeadWatch(t *testing.T) { + x := newFollow(t) + x.bus.current = json.RawMessage(`{"text":"9.0 W CPU"}`) + x.f.Round(x.bar) + x.bus.current, x.bus.keyFound = nil, false + for i := 0; i < 3; i++ { + x.now = x.now.Add(Silent + time.Second) + x.f.Round(x.bar) + } + if len(x.bus.watched) != 1 || x.shown() != "" { + t.Fatalf("watched %d times, shows %q", len(x.bus.watched), x.shown()) + } + for _, s := range x.said { + if strings.Contains(s, "watching it again") { + t.Fatalf("an expired key was taken for a dead watch: %v", x.said) + } + } + if n := strings.Count(strings.Join(x.said, "|"), "expired"); n != 1 { + t.Fatalf("expiry said %d times: %v", n, x.said) + } +} + +func TestARemovedBlockIsNoLongerKept(t *testing.T) { + x := newFollow(t) + x.bus.current = json.RawMessage(`{"text":"9.0 W CPU"}`) + x.f.Round(x.bar) + x.f.Round(barFiles(t, "no values here")) + if x.shown() != "" || !strings.Contains(x.said[len(x.said)-1], "no longer kept") { + t.Fatalf("%q %v", x.shown(), x.said) + } + if err := x.bus.latest()("put", json.RawMessage(`{"text":"after"}`)); err != nil || x.shown() != "" { + t.Fatal("a stopped watch still writes") + } +} + +func TestAWatchAndAReadWritingAtOnceLeaveOneWholeValue(t *testing.T) { + dir := t.TempDir() + w := Wanted{"power.draw", "laptop"} + done := make(chan error, 20) + for i := 0; i < 20; i++ { + go func(i int) { + done <- keep(dir, w, "put", json.RawMessage(`{"text":"value `+strings.Repeat("x", i)+`"}`)) + }(i) + } + for i := 0; i < 20; i++ { + if err := <-done; err != nil { + t.Fatal(err) + } + } + b, _ := os.ReadFile(filepath.Join(dir, "power.draw@laptop")) + if !strings.HasPrefix(string(b), "value ") || strings.Count(string(b), "\n") != 1 { + t.Fatalf("%q", b) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + t.Fatalf("temporary files left: %v", entries) + } +} + +func TestAKeyIsAMachinesName(t *testing.T) { + if w := wantedIn(barFiles(t, `i3status-value power.draw ../etc`+"\n"+`i3status-value power.draw Laptop`)); len(w) != 0 { + t.Fatalf("%v", w) + } +} + +func TestTheBlocksCommandShowsOnlyAFreshValue(t *testing.T) { + runtime := t.TempDir() + dir := filepath.Join(runtime, "i3status-rust", "values") + run := func() string { + cmd := exec.Command("sh", "../../files/bin/i3status-value", "power.draw", "laptop") + cmd.Env = append(os.Environ(), "XDG_RUNTIME_DIR="+runtime) + out, err := cmd.Output() + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(string(out)) + } + if got := run(); got != "" { + t.Fatalf("no value: %q", got) + } + fresh := time.Now().UTC().Format(time.RFC3339) + if err := keep(dir, Wanted{"power.draw", "laptop"}, "put", json.RawMessage(`{"text":"8.0 W CPU","at":"`+fresh+`"}`)); err != nil { + t.Fatal(err) + } + if got := run(); got != "8.0 W CPU" { + t.Fatalf("fresh: %q", got) + } + // A value measured 31 s ago hides, however recently it arrived. + old := time.Now().Add(-31 * time.Second).UTC().Format(time.RFC3339) + if err := keep(dir, Wanted{"power.draw", "laptop"}, "put", json.RawMessage(`{"text":"8.0 W CPU","at":"`+old+`"}`)); err != nil { + t.Fatal(err) + } + if got := run(); got != "" { + t.Fatalf("measured 31 s ago: %q", got) + } +} diff --git a/modules/i3status-rust/files/bin/i3status-value b/modules/i3status-rust/files/bin/i3status-value new file mode 100755 index 00000000..8fea95e4 --- /dev/null +++ b/modules/i3status-rust/files/bin/i3status-value @@ -0,0 +1,13 @@ +#!/bin/sh +# i3status-value (module i3status-rust, novox/hq ADR 0260): one value another module keeps +# on the bus, for a block it contributed. +# +# This module's own tools bundle watches the state on the bus (the mesh grants it the read) and leaves +# the value's text in this module's own directory; this prints it. A value not refreshed for thirty +# seconds is one its module stopped giving, and prints nothing, so the block hides rather than show an +# old number as if it were now. +dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/i3status-rust/values" +f="$dir/$1@$2" +if [ -r "$f" ] && [ $(( $(date +%s) - $(stat -c %Y "$f") )) -le 30 ]; then + head -n 1 "$f" +fi diff --git a/modules/i3status-rust/files/block.tmpl b/modules/i3status-rust/files/block.tmpl new file mode 100644 index 00000000..527a6426 --- /dev/null +++ b/modules/i3status-rust/files/block.tmpl @@ -0,0 +1,26 @@ +{{- /* A block other modules contribute, in i3status-rust's grammar (novox/hq ADR 0255). The controller +renders one piece at a time: its fields (bar, place, order, shows, options), module and machine. */ -}} +{{- if eq .shows "battery" -}} +[[block]] +block = "battery" +{{with .options}}{{with .device}}device = {{quote .}} +{{end}}{{end -}} +format = "$icon $percentage{ $time|}" +charging_format = "$icon $percentage{ $time|}" +not_charging_format = "$icon $percentage" +full_format = "$icon $percentage" +empty_format = "$icon $percentage" +missing_format = "" +{{- else if eq .shows "command" -}} +[[block]] +block = "custom" +command = {{quote .options.command}} +interval = {{.options.interval}} +{{- else if eq .shows "state" -}} +[[block]] +block = "custom" +command = {{quote (printf "~/.local/bin/i3status-value %s %s" .options.state (or .options.key .machine))}} +interval = 5 +hide_when_empty = true +{{- end}} + diff --git a/modules/i3status-rust/files/bottom-bar.toml b/modules/i3status-rust/files/bottom-bar.toml index 80980938..58ef2029 100644 --- a/modules/i3status-rust/files/bottom-bar.toml +++ b/modules/i3status-rust/files/bottom-bar.toml @@ -1,7 +1,8 @@ # The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every # push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one -# person's devices left out: the battery and the GPU belong to a machine model's hardware module, -# and a headset's address is not the bar's to know. Every block here works the same on any machine. +# person's devices left out: a block that depends on the hardware is contributed by the module that +# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written +# here works the same on any machine. icons_format = "{icon}" [theme] @@ -66,9 +67,14 @@ format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))" [[block]] block = "memory" -format = "$icon $mem_total_used.eng(w:2) ($mem_total_used_percents.eng(w:2))" +# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure +# module judges by. Counting the cache as used read near full on a machine with room to spare. +format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))" format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))" +# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from +# what they show by this module's template, and only on a machine that has what they show. +${contribution:node-bar:block:bar=bottom,place=resources} [[block]] block = "docker" interval = 2 @@ -80,6 +86,9 @@ block = "sound" button = "left" cmd = "pavucontrol" +# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and +# power draw, where the machine has them. +${contribution:node-bar:block:bar=bottom,place=status} # The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is. [[block]] block = "weather" diff --git a/modules/i3status-rust/files/top-bar.toml b/modules/i3status-rust/files/top-bar.toml index 11660368..b767a765 100644 --- a/modules/i3status-rust/files/top-bar.toml +++ b/modules/i3status-rust/files/top-bar.toml @@ -31,3 +31,7 @@ short = " $title.str(max_w:100) |" [[block]] block = "uptime" interval = 60 + +# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept +# so that one that does is never dropped. +${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status} diff --git a/modules/i3status-rust/module.json b/modules/i3status-rust/module.json index ad647882..373338f4 100644 --- a/modules/i3status-rust/module.json +++ b/modules/i3status-rust/module.json @@ -10,7 +10,10 @@ "claims": [ { "name": "node-bar", - "scope": "node" + "scope": "node", + "renders": { + "block": "{{- /* A block other modules contribute, in i3status-rust's grammar (novox/hq ADR 0255). The controller\nrenders one piece at a time: its fields (bar, place, order, shows, options), module and machine. */ -}}\n{{- if eq .shows \"battery\" -}}\n[[block]]\nblock = \"battery\"\n{{with .options}}{{with .device}}device = {{quote .}}\n{{end}}{{end -}}\nformat = \"$icon $percentage{ $time|}\"\ncharging_format = \"$icon $percentage{ $time|}\"\nnot_charging_format = \"$icon $percentage\"\nfull_format = \"$icon $percentage\"\nempty_format = \"$icon $percentage\"\nmissing_format = \"\"\n{{- else if eq .shows \"command\" -}}\n[[block]]\nblock = \"custom\"\ncommand = {{quote .options.command}}\ninterval = {{.options.interval}}\n{{- else if eq .shows \"state\" -}}\n[[block]]\nblock = \"custom\"\ncommand = {{quote (printf \"~/.local/bin/i3status-value %s %s\" .options.state (or .options.key .machine))}}\ninterval = 5\nhide_when_empty = true\n{{- end}}\n\n" + } } ], "tools": [ @@ -52,7 +55,7 @@ "path": "${machine:account-home}/.config/i3status-rust/top-bar.toml", "owner": "${machine:account}", "mode": "0644", - "content": "# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.\n# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"focused_window\"\n[block.format]\nfull = \" $title.str(max_w:50) |\"\nshort = \" $title.str(max_w:100) |\"\n\n[[block]]\nblock = \"uptime\"\ninterval = 60\n" + "content": "# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.\n# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"focused_window\"\n[block.format]\nfull = \" $title.str(max_w:50) |\"\nshort = \" $title.str(max_w:100) |\"\n\n[[block]]\nblock = \"uptime\"\ninterval = 60\n\n# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept\n# so that one that does is never dropped.\n${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}\n" }, { "id": "bottom-bar", @@ -60,7 +63,7 @@ "path": "${machine:account-home}/.config/i3status-rust/bottom-bar.toml", "owner": "${machine:account}", "mode": "0644", - "content": "# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one\n# person's devices left out: the battery and the GPU belong to a machine model's hardware module,\n# and a headset's address is not the bar's to know. Every block here works the same on any machine.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"tea_timer\"\nformat = \"$icon{ $minutes:$seconds|}\"\ndone_cmd = \"notify-send 'Timer finished'\"\n\n# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good\n# count through a network gap and shows an error only after ten failed checks in a row. Click: the\n# list, in the launcher's menu.\n[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-updates 10\"\njson = true\ninterval = 1800\n[[block.click]]\nbutton = \"left\"\ncmd = \"checkupdates | dmenu -l 20 -p Updates\"\n\n[[block]]\nblock = \"cpu\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon / $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/home\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon /home $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"memory\"\nformat = \"$icon $mem_total_used.eng(w:2) ($mem_total_used_percents.eng(w:2))\"\nformat_alt = \"$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))\"\n\n[[block]]\nblock = \"docker\"\ninterval = 2\nformat = \"$icon $running/$total\"\n\n[[block]]\nblock = \"sound\"\n[[block.click]]\nbutton = \"left\"\ncmd = \"pavucontrol\"\n\n# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.\n[[block]]\nblock = \"weather\"\nformat = \"$icon $weather_verbose ($location) $temp\"\nautolocate = true\nautolocate_interval = 600\n[block.service]\nname = \"metno\"\n\n[[block]]\nblock = \"notify\"\ndriver = \"dunst\"\nformat = \" $icon {($notification_count.eng(w:1))|}\"\n\n[[block]]\nblock = \"time\"\ninterval = 1\nformat = \"$timestamp.datetime(f:'%a %d/%m %H:%M:%S')\"\n" + "content": "# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one\n# person's devices left out: a block that depends on the hardware is contributed by the module that\n# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written\n# here works the same on any machine.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"tea_timer\"\nformat = \"$icon{ $minutes:$seconds|}\"\ndone_cmd = \"notify-send 'Timer finished'\"\n\n# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good\n# count through a network gap and shows an error only after ten failed checks in a row. Click: the\n# list, in the launcher's menu.\n[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-updates 10\"\njson = true\ninterval = 1800\n[[block.click]]\nbutton = \"left\"\ncmd = \"checkupdates | dmenu -l 20 -p Updates\"\n\n[[block]]\nblock = \"cpu\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon / $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/home\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon /home $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"memory\"\n# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure\n# module judges by. Counting the cache as used read near full on a machine with room to spare.\nformat = \"$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))\"\nformat_alt = \"$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))\"\n\n# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from\n# what they show by this module's template, and only on a machine that has what they show.\n${contribution:node-bar:block:bar=bottom,place=resources}\n[[block]]\nblock = \"docker\"\ninterval = 2\nformat = \"$icon $running/$total\"\n\n[[block]]\nblock = \"sound\"\n[[block.click]]\nbutton = \"left\"\ncmd = \"pavucontrol\"\n\n# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and\n# power draw, where the machine has them.\n${contribution:node-bar:block:bar=bottom,place=status}\n# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.\n[[block]]\nblock = \"weather\"\nformat = \"$icon $weather_verbose ($location) $temp\"\nautolocate = true\nautolocate_interval = 600\n[block.service]\nname = \"metno\"\n\n[[block]]\nblock = \"notify\"\ndriver = \"dunst\"\nformat = \" $icon {($notification_count.eng(w:1))|}\"\n\n[[block]]\nblock = \"time\"\ninterval = 1\nformat = \"$timestamp.datetime(f:'%a %d/%m %H:%M:%S')\"\n" }, { "id": "icons", @@ -85,6 +88,14 @@ "owner": "${machine:account}", "mode": "0755", "content": "#!/usr/bin/env bash\n# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died.\n#\n# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not\n# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the\n# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the\n# cause, which is i3bar's.\n#\n# Started once per session from the session's start, with the session's own pid; it ends when that\n# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04.\nset -uo pipefail\n\nsession_pid=\"${1:-}\"\ninterval=\"${I3_BAR_WATCHDOG_INTERVAL:-5}\"\n# Two misses in a row before acting: an i3 restart tears every bar down and starts them again.\nconfirm=\"${I3_BAR_WATCHDOG_CONFIRM:-2}\"\n# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop.\ncooldown=\"${I3_BAR_WATCHDOG_COOLDOWN:-30}\"\n\nlog() { printf 'i3bar-watchdog: %s\\n' \"$*\" >&2; }\n\nfor tool in i3-msg i3bar pgrep; do\n\tcommand -v \"$tool\" >/dev/null 2>&1 || {\n\t\tlog \"$tool is missing; not watching\"\n\t\texit 1\n\t}\ndone\n\ndeclare -A missed=() fixed=()\n\nbar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '\"[^\"]+\"' | tr -d '\"'; }\noutputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2\" \"$3}' | tr '\\n' ' '; }\nsession_alive() { [ -z \"$session_pid\" ] || kill -0 \"$session_pid\" 2>/dev/null; }\n\nwhile session_alive; do\n\tsleep \"$interval\"\n\tsocket=\"$(i3 --get-socketpath 2>/dev/null)\"\n\t[ -n \"$socket\" ] && [ -S \"$socket\" ] || continue\n\tids=\"$(bar_ids)\"\n\t[ -n \"$ids\" ] || continue\n\twhile read -r id; do\n\t\t[ -n \"$id\" ] || continue\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\t\tcontinue\n\t\tfi\n\t\tmissed[$id]=$((${missed[$id]:-0} + 1))\n\t\t[ \"${missed[$id]}\" -ge \"$confirm\" ] || continue\n\t\tnow=\"$(date +%s)\"\n\t\tif [ $((now - ${fixed[$id]:-0})) -lt \"$cooldown\" ]; then\n\t\t\tcontinue\n\t\tfi\n\t\tlog \"$id is gone; respawning it. Outputs now: $(outputs)\"\n\t\tnohup i3bar --bar_id=\"$id\" --socket=\"$socket\" >/dev/null 2>&1 &\n\t\tdisown 2>/dev/null || true\n\t\tfixed[$id]=\"$now\"\n\t\tsleep 2\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\telse\n\t\t\tlog \"$id exited within 2s of respawning; check its status_command\"\n\t\tfi\n\tdone <<<\"$ids\"\ndone\n" + }, + { + "id": "value", + "type": "file", + "path": "${machine:account-home}/.local/bin/i3status-value", + "owner": "${machine:account}", + "mode": "0755", + "content": "#!/bin/sh\n# i3status-value (module i3status-rust, novox/hq ADR 0260): one value another module keeps\n# on the bus, for a block it contributed.\n#\n# This module's own tools bundle watches the state on the bus (the mesh grants it the read) and leaves\n# the value's text in this module's own directory; this prints it. A value not refreshed for thirty\n# seconds is one its module stopped giving, and prints nothing, so the block hides rather than show an\n# old number as if it were now.\ndir=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/i3status-rust/values\"\nf=\"$dir/$1@$2\"\nif [ -r \"$f\" ] && [ $(( $(date +%s) - $(stat -c %Y \"$f\") )) -le 30 ]; then\n\thead -n 1 \"$f\"\nfi\n" } ], "build": { diff --git a/modules/power/README.md b/modules/power/README.md index f85d44ad..d1076525 100644 --- a/modules/power/README.md +++ b/modules/power/README.md @@ -13,6 +13,7 @@ states on the bus. | `/etc/mesh-power/moments/` | for each moment, every module's code as the controller placed it (`${shell::}`) | | `/usr/local/lib/mesh-power/bin/power-moment` | the runner: each module's piece on its own, as root, with `sh`, bounded (`MESH_POWER_BOUND`, default 30 s); every outcome in the journal under `mesh-power` | | five units | `mesh-power-after-boot`, `-before-shutdown` (its stop is the shutdown, while the network is still up), `-before-sleep`, `-after-wake`, `-supply` | +| `mesh-power-draw` (process) | samples the power drawn for the module's bundle, which puts it on the bus as `power.draw` (below) | | `/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 @@ -31,6 +32,52 @@ Code that must run *inside* the sleep transaction, such as the NVIDIA driver's o resume units, is not a contribution. Its module keeps its own drop-ins on the sleep services (ADR 0211, "What got harder"). +## The bar's battery and power draw (novox/hq ADR 0255, ADR 0260) + +The module holds the machine's power everywhere, so it gives the bar what is generic about power, on +the machines that have it. Both are contributions to `node-bar` in the bar's own neutral terms, so any +bar renders them, and each names the capability the node-engine reports for it: + +| block | where | what it shows | +|---|---|---| +| battery | `if-capability: battery` | the bar's own battery block: the bar reads the kernel's power-supply class itself, not anything of this module's | +| power draw | `if-capability: power-meter` | this module's state `power.draw` for the machine, over the bus | + +The power draw says what it measured: + +- **on battery**, the battery's discharge: `18.2 W machine`; +- **otherwise**, the processor package's power from the kernel's powercap energy counter (RAPL, Intel + and AMD), plus a discrete graphics device's own power where it reports one: `14.0 W CPU+GPU` or + `14.0 W CPU`. That is part of the machine, and is called that part. +- **The machine at the wall** needs a metered plug or a UPS and is not measured. + +**Over the bus, not through a file.** Modules talk over the bus. The energy counter is root's to read, +on purpose: read often by anyone, it leaks what the processor is doing (the PLATYPUS attack), so no +permission is loosened. Two parts of this module carry the reading: + +1. the process `mesh-power-draw` (`power draw`, as root) samples every 5 s and leaves the averaged rate + in `/run/mesh-power/draw`, this module's own directory, read by nothing but this module; +2. the module's bundle, which the runtime launches, puts each fresh reading in its state `draw`, keyed by + the machine's name: `{"text", "watts", "measures", "at"}`. A value lives 30 s (`ttl-seconds`). The + state is `per-machine`: each machine's instance is granted its own machine's key and no other. + +**State, not events, by bus load.** A reading is the current value and is replaced every five seconds; +nobody needs the ones before. A key per machine keeps one value, so the bus holds at most one reading +per machine, and a bar starting later reads it at once. An event every five seconds would keep each one +in a stream. On four machines the puts are under one message a second. + +The bar's holder is granted the read by the mesh, because this module offers the state in its block; it +names no module. Graphics are counted honestly: an AMD device only when it has its own memory (an APU's +integrated graphics report the whole package, which the counter already holds), and only while it is +awake; an NVIDIA device through `nvidia-smi` only while it is awake. Each package is counted once by its +name: where Intel shows one twice (`intel-rapl:N` and `intel-rapl-mmio:N`), the first is read. A counter +that gives more than 1000 W was reset, after a resume say, and that sample gives no reading. + +**The sampler runs wherever this module is assigned, servers included.** A server has the energy +counter too, so conditioning it on `power-meter` would not keep it off one, and the mesh has no way yet +to condition a resource on another seat (the bar) being held on the machine. It costs one process +reading a few kernel files every five seconds (novox/hq ADR 0255 §9). + ## Settings `handle-power-key`, `handle-lid-switch`, `handle-lid-switch-external-power` and diff --git a/modules/power/cmd/power/draw.go b/modules/power/cmd/power/draw.go new file mode 100644 index 00000000..eae75998 --- /dev/null +++ b/modules/power/cmd/power/draw.go @@ -0,0 +1,301 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// The power the machine draws, measured honestly and said with what was measured (novox/hq ADR 0255). +// +// - **On battery,** the battery's discharge rate. That is the whole machine. +// - **Otherwise** — on mains, or on a machine without a battery — the processor package's power from +// the kernel's powercap energy counter (RAPL, on Intel and AMD alike: a rate from the counter's +// change), plus a discrete graphics device's own power where it reports one. That is the processor +// (and graphics) part, never called the machine's. +// - **The machine at the wall** needs a metered plug or a UPS, and is not measured here. +// +// The energy counter is root's to read, on purpose: read often, it leaks what the processor is doing +// (the PLATYPUS attack). So the counter is never opened to the operator's account. `power draw` runs as +// root, as the module's process, samples every SampleEvery, and writes only the averaged rate to +// DrawFile, too coarse to leak anything. That file is this module's alone: its bundle reads it and puts +// the reading on the bus as the state `draw`, keyed by the machine (publish.go), and nothing of another +// module reads it (novox/hq ADR 0260: modules talk over the bus). + +// DrawFile is where the sampler leaves the reading for the module's bundle, and SampleEvery how often. +const ( + DrawFile = "/run/mesh-power/draw" + SampleEvery = 5 * time.Second +) + +// Draw is one reading: each part measured, in watts, and what it covers. +type Draw struct { + // Machine is the battery's discharge, when on battery: the whole machine. + Machine *float64 + // CPU is the processor packages' power, from the energy counters. + CPU *float64 + // GPU is the discrete graphics devices' own power, where they report it; asleep counts as none. + GPU *float64 +} + +// Reading is a draw as the state `draw` holds it: the line shown, the watts, what they cover and when. +type Reading struct { + Text string `json:"text"` + Watts float64 `json:"watts,omitempty"` + Measures string `json:"measures,omitempty"` + At string `json:"at"` +} + +// Reading is the draw as it is published, at a moment. +func (d Draw) Reading(at time.Time) Reading { + r := Reading{Text: d.Line(), At: at.UTC().Format(time.RFC3339)} + switch { + case d.Machine != nil: + r.Watts, r.Measures = *d.Machine, "machine" + case d.CPU != nil && d.GPU != nil: + r.Watts, r.Measures = *d.CPU+*d.GPU, "cpu+gpu" + case d.CPU != nil: + r.Watts, r.Measures = *d.CPU, "cpu" + case d.GPU != nil: + r.Watts, r.Measures = *d.GPU, "gpu" + } + return r +} + +// Line is the reading as the bar shows it. +func (d Draw) Line() string { + switch { + case d.Machine != nil: + return fmt.Sprintf("%.1f W machine", *d.Machine) + case d.CPU != nil && d.GPU != nil: + return fmt.Sprintf("%.1f W CPU+GPU", *d.CPU+*d.GPU) + case d.CPU != nil: + return fmt.Sprintf("%.1f W CPU", *d.CPU) + case d.GPU != nil: + return fmt.Sprintf("%.1f W GPU", *d.GPU) + } + return "power not measured" +} + +// counter is one energy counter's reading, in microjoules, with the range it wraps at. +type counter struct { + uj, max int64 +} + +// MaxPackageWatts is more than any processor package draws. A rate above it is a counter that was +// reset — after a resume, say — not power, and that sample gives no reading. +const MaxPackageWatts = 1000 + +// packageCounters is every processor package's energy counter, by the package's name, each counted +// once. Intel exposes some packages twice, through MSRs (`intel-rapl:N`) and through MMIO +// (`intel-rapl-mmio:N`), under one name; the MSR one is read, and the second never adds to it. +func (m *Machine) packageCounters() map[string]counter { + out := map[string]counter{} + from := map[string]string{} + for _, dir := range m.glob("/sys/class/powercap/*") { + name := m.read(dir + "/name") + if !strings.HasPrefix(name, "package") { + continue + } + if was, seen := from[name]; seen && !strings.Contains(filepath.Base(was), "mmio") { + continue + } + uj, ok := m.readInt(dir + "/energy_uj") + if !ok { + continue + } + max, _ := m.readInt(dir + "/max_energy_range_uj") + out[name] = counter{uj: uj, max: max} + from[name] = dir + } + return out +} + +// cpuWatts is the packages' power between two readings `elapsed` apart; nil when no counter was read +// both times. A counter that wrapped is counted across its range; one that gives a rate no package +// draws was reset, and the sample gives nothing rather than a number that is not power. +func cpuWatts(before, after map[string]counter, elapsed time.Duration) *float64 { + if elapsed <= 0 { + return nil + } + var joules float64 + read := false + for dir, a := range after { + b, ok := before[dir] + if !ok { + continue + } + delta := a.uj - b.uj + if delta < 0 { + if a.max <= 0 { + continue + } + delta += a.max + } + if float64(delta)/1e6/elapsed.Seconds() > MaxPackageWatts { + return nil + } + joules += float64(delta) / 1e6 + read = true + } + if !read { + return nil + } + w := joules / elapsed.Seconds() + return &w +} + +// batteryWatts is the system battery's discharge, when it is discharging; nil otherwise. +func (m *Machine) batteryWatts() *float64 { + var total float64 + found := false + for _, s := range m.Supplies() { + if s.Type != "Battery" || s.Device || s.Status != "Discharging" { + continue + } + dir := "/sys/class/power_supply/" + s.Name + if p, ok := m.readInt(dir + "/power_now"); ok { + total += float64(abs(p)) / 1e6 + found = true + continue + } + i, iok := m.readInt(dir + "/current_now") + v, vok := m.readInt(dir + "/voltage_now") + if iok && vok { + total += float64(abs(i)) * float64(v) / 1e12 + found = true + } + } + if !found { + return nil + } + return &total +} + +func abs(n int64) int64 { + if n < 0 { + return -n + } + return n +} + +// gpuWatts is the discrete graphics devices' own power, where they report it; nil when none does. +// +// - An AMD device's hwmon power counts only when it has its own memory (`mem_info_vram_vendor`): on +// an APU the integrated graphics report the whole package, which the processor's counter already +// holds, and counting it twice would be a lie. +// - An NVIDIA device is asked through nvidia-smi only while it is awake: asking wakes a suspended GPU, +// which on a hybrid laptop costs more than the answer is worth. Asleep, it draws next to nothing and +// counts as none. +func (m *Machine) gpuWatts(ctx context.Context) *float64 { + var total float64 + found := false + for _, card := range m.glob("/sys/class/drm/card[0-9]*") { + if strings.Contains(filepath.Base(card), "-") || !m.exists(card+"/device/mem_info_vram_vendor") { + continue + } + // Asleep, it is not read: reading its sensors wakes it, as asking nvidia-smi would. + if status := m.read(card + "/device/power/runtime_status"); status != "" && status != "active" { + continue + } + for _, hw := range m.glob(card + "/device/hwmon/hwmon*") { + uw, ok := m.readInt(hw + "/power1_average") + if !ok { + uw, ok = m.readInt(hw + "/power1_input") + } + if ok { + total += float64(uw) / 1e6 + found = true + break + } + } + } + for _, gpu := range m.glob("/proc/driver/nvidia/gpus/*") { + address := filepath.Base(gpu) + if m.read("/sys/bus/pci/devices/"+address+"/power/runtime_status") != "active" { + continue + } + out, err := m.Run(ctx, "nvidia-smi", "--query-gpu=power.draw", "--format=csv,noheader,nounits", "-i", address) + if err != nil { + continue + } + if w, err := strconv.ParseFloat(strings.TrimSpace(out), 64); err == nil { + total += w + found = true + } + } + if !found { + return nil + } + return &total +} + +// Sample is one reading over `every`: the battery's discharge if the machine is on battery, else the +// processor's and the graphics' parts. +func (m *Machine) Sample(ctx context.Context, every time.Duration, wait func(time.Duration) bool) Draw { + if w := m.batteryWatts(); w != nil { + return Draw{Machine: w} + } + before, start := m.packageCounters(), time.Now() + if !wait(every) { + return Draw{} + } + return Draw{CPU: cpuWatts(before, m.packageCounters(), time.Since(start)), GPU: m.gpuWatts(ctx)} +} + +// Publish writes the reading where the module's bundle reads it, whole, replacing the last. +func (m *Machine) Publish(r Reading) error { + raw, err := json.Marshal(r) + if err != nil { + return err + } + line := string(raw) + path := m.path(DrawFile) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".new" + if err := os.WriteFile(tmp, []byte(line+"\n"), 0o644); err != nil { + return err + } + if err := os.Chmod(tmp, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// RunDraw samples and publishes until the context ends. +func RunDraw(ctx context.Context, m *Machine) error { + return runDraw(ctx, m, SampleEvery, m.Publish) +} + +// runDraw publishes one reading every `every`. A battery reading is instant, so it waits the interval +// itself; a counter reading waits inside its sample. It returns as soon as the context ends. +func runDraw(ctx context.Context, m *Machine, every time.Duration, publish func(Reading) error) error { + wait := func(d time.Duration) bool { + select { + case <-ctx.Done(): + return false + case <-time.After(d): + return true + } + } + for ctx.Err() == nil { + d := m.Sample(ctx, every, wait) + if ctx.Err() != nil { + break + } + if err := publish(d.Reading(time.Now())); err != nil { + return fmt.Errorf("publishing the draw: %w", err) + } + if d.Machine != nil && !wait(every) { + break + } + } + return nil +} diff --git a/modules/power/cmd/power/draw_test.go b/modules/power/cmd/power/draw_test.go new file mode 100644 index 00000000..6b06a449 --- /dev/null +++ b/modules/power/cmd/power/draw_test.go @@ -0,0 +1,210 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Defends novox/hq ADR 0255: the power drawn is measured honestly and says what it measured — the +// machine on battery, the processor (and discrete graphics) otherwise — and only a rate leaves root. + +func tree(t *testing.T, files map[string]string) *Machine { + t.Helper() + root := t.TempDir() + for p, c := range files { + full := filepath.Join(root, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(c+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + return &Machine{Root: root, Run: func(context.Context, string, ...string) (string, error) { + return "", errors.New("nothing may be run here") + }} +} + +func noWait(time.Duration) bool { return true } + +func TestOnBatteryTheDischargeIsTheMachine(t *testing.T) { + m := tree(t, map[string]string{ + "/sys/class/power_supply/BAT1/type": "Battery", + "/sys/class/power_supply/BAT1/status": "Discharging", + "/sys/class/power_supply/BAT1/current_now": "-1500000", + "/sys/class/power_supply/BAT1/voltage_now": "16000000", + // A counter is there, and not what a battery reading reports. + "/sys/class/powercap/intel-rapl:0/name": "package-0", + "/sys/class/powercap/intel-rapl:0/energy_uj": "1", + }) + d := m.Sample(context.Background(), time.Second, func(time.Duration) bool { t.Fatal("a battery reading waited"); return false }) + if got := d.Line(); got != "24.0 W machine" { + t.Fatalf("%q", got) + } +} + +func TestOnMainsTheProcessorIsMeasuredAndCalledTheProcessor(t *testing.T) { + m := tree(t, map[string]string{ + "/sys/class/power_supply/BAT1/type": "Battery", + "/sys/class/power_supply/BAT1/status": "Not charging", + "/sys/class/powercap/intel-rapl:0/name": "package-0", + "/sys/class/powercap/intel-rapl:0/energy_uj": "1000000", + "/sys/class/powercap/intel-rapl:0/max_energy_range_uj": "65532610987", + "/sys/class/powercap/intel-rapl:0:0/name": "core", + "/sys/class/powercap/intel-rapl:0:0/energy_uj": "1", + // An APU's integrated graphics report the whole package: not counted a second time. + "/sys/class/drm/card1/device/hwmon/hwmon5/power1_average": "19000000", + }) + counter := filepath.Join(m.Root, "/sys/class/powercap/intel-rapl:0/energy_uj") + // 1 J over 50 ms: 20 W, well inside what a package draws. + d := m.Sample(context.Background(), time.Second, func(time.Duration) bool { + time.Sleep(50 * time.Millisecond) + return os.WriteFile(counter, []byte("2000000\n"), 0o644) == nil + }) + if d.Machine != nil || d.CPU == nil || d.GPU != nil || *d.CPU <= 0 { + t.Fatalf("%+v", d) + } + if !strings.HasSuffix(d.Line(), " W CPU") { + t.Fatalf("%q", d.Line()) + } +} + +func TestACounterThatWrappedIsCountedAcrossItsRange(t *testing.T) { + before := map[string]counter{"p": {uj: 90, max: 100}} + after := map[string]counter{"p": {uj: 10, max: 100}} + w := cpuWatts(before, after, time.Second) + if w == nil || *w != 20e-6 { + t.Fatalf("%v", w) + } + if cpuWatts(before, map[string]counter{"other": {uj: 1}}, time.Second) != nil { + t.Fatal("a counter read once was taken for a rate") + } +} + +func TestADiscreteGraphicsDevicesPowerIsAddedAndASleepingOneIsNotWoken(t *testing.T) { + m := tree(t, map[string]string{ + "/sys/class/drm/card0/device/mem_info_vram_vendor": "samsung", + "/sys/class/drm/card0/device/hwmon/hwmon3/power1_average": "30000000", + "/sys/class/drm/card0-DP-1/status": "connected", + "/proc/driver/nvidia/gpus/0000:01:00.0/information": "x", + "/sys/bus/pci/devices/0000:01:00.0/power/runtime_status": "suspended", + }) + if w := m.gpuWatts(context.Background()); w == nil || *w != 30 { + t.Fatalf("%v", w) + } + // Awake, it is asked. + if err := os.WriteFile(filepath.Join(m.Root, "/sys/bus/pci/devices/0000:01:00.0/power/runtime_status"), []byte("active\n"), 0o644); err != nil { + t.Fatal(err) + } + m.Run = func(_ context.Context, name string, args ...string) (string, error) { + if name != "nvidia-smi" || args[len(args)-1] != "0000:01:00.0" { + t.Fatalf("ran %s %v", name, args) + } + return "12.50\n", nil + } + if w := m.gpuWatts(context.Background()); w == nil || *w != 42.5 { + t.Fatalf("%v", w) + } + cpu, gpu := 10.0, 42.5 + if got := (Draw{CPU: &cpu, GPU: &gpu}).Line(); got != "52.5 W CPU+GPU" { + t.Fatalf("%q", got) + } +} + +func TestNothingToMeasureIsSaid(t *testing.T) { + m := tree(t, map[string]string{"/sys/class/power_supply/ACAD/type": "Mains"}) + if got := m.Sample(context.Background(), time.Second, noWait).Line(); got != "power not measured" { + t.Fatalf("%q", got) + } +} + +func TestOnlyTheRateIsLeftForTheBundle(t *testing.T) { + m := tree(t, nil) + w := 8.0 + if err := m.Publish(Draw{CPU: &w}.Reading(time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC))); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(m.Root, DrawFile)) + if err != nil || info.Mode().Perm() != 0o644 { + t.Fatalf("%v %v", info, err) + } + b, _ := os.ReadFile(filepath.Join(m.Root, DrawFile)) + if string(b) != `{"text":"8.0 W CPU","watts":8,"measures":"cpu","at":"2026-10-08T12:00:00Z"}`+"\n" { + t.Fatalf("%q", b) + } +} + +func TestAPackageSeenTwiceIsCountedOnce(t *testing.T) { + m := tree(t, map[string]string{ + "/sys/class/powercap/intel-rapl:0/name": "package-0", + "/sys/class/powercap/intel-rapl:0/energy_uj": "100", + "/sys/class/powercap/intel-rapl-mmio:0/name": "package-0", + "/sys/class/powercap/intel-rapl-mmio:0/energy_uj": "999", + "/sys/class/powercap/intel-rapl:1/name": "package-1", + "/sys/class/powercap/intel-rapl:1/energy_uj": "5", + }) + got := m.packageCounters() + if len(got) != 2 || got["package-0"].uj != 100 || got["package-1"].uj != 5 { + t.Fatalf("%+v", got) + } +} + +func TestACounterResetGivesNoReading(t *testing.T) { + // After a resume the counter starts again near nothing: across its range that would be thousands + // of watts, which is no reading at all. + before := map[string]counter{"package-0": {uj: 40_000_000_000, max: 65_532_610_987}} + after := map[string]counter{"package-0": {uj: 1_000_000, max: 65_532_610_987}} + if w := cpuWatts(before, after, 5*time.Second); w != nil { + t.Fatalf("a reset counter read as %.0f W", *w) + } + // A true wrap, near the end of the range, is still counted. + before = map[string]counter{"package-0": {uj: 65_532_610_987 - 10_000_000, max: 65_532_610_987}} + after = map[string]counter{"package-0": {uj: 40_000_000, max: 65_532_610_987}} + if w := cpuWatts(before, after, 5*time.Second); w == nil || *w != 10 { + t.Fatalf("a wrapped counter read as %v", w) + } +} + +func TestASleepingAMDCardIsNotRead(t *testing.T) { + m := tree(t, map[string]string{ + "/sys/class/drm/card0/device/mem_info_vram_vendor": "samsung", + "/sys/class/drm/card0/device/power/runtime_status": "suspended", + "/sys/class/drm/card0/device/hwmon/hwmon3/power1_average": "30000000", + }) + if w := m.gpuWatts(context.Background()); w != nil { + t.Fatalf("a suspended card was read: %v", *w) + } +} + +func TestTheLoopPublishesEveryIntervalAndStopsWhenCancelled(t *testing.T) { + m := tree(t, map[string]string{ + "/sys/class/power_supply/BAT0/type": "Battery", + "/sys/class/power_supply/BAT0/status": "Discharging", + "/sys/class/power_supply/BAT0/power_now": "12000000", + }) + ctx, cancel := context.WithCancel(context.Background()) + var lines []string + done := make(chan error) + go func() { + done <- runDraw(ctx, m, 20*time.Millisecond, func(r Reading) error { lines = append(lines, r.Text); return nil }) + }() + time.Sleep(210 * time.Millisecond) + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("the loop did not stop when cancelled") + } + // One at once, then one every 20 ms: about eleven in 210 ms, never a burst, never a stall. + if len(lines) < 6 || len(lines) > 12 || lines[0] != "12.0 W machine" { + t.Fatalf("%d lines: %v", len(lines), lines) + } +} diff --git a/modules/power/cmd/power/main.go b/modules/power/cmd/power/main.go index 1221ae74..972a39fa 100644 --- a/modules/power/cmd/power/main.go +++ b/modules/power/cmd/power/main.go @@ -1,22 +1,42 @@ // 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. +// +// Started as `power draw` it is the module's other process instead: the sampler that publishes the +// power the machine draws for the bar, as root, because the energy counter is root's (draw.go). package main import ( "context" "fmt" "os" + "os/signal" + "syscall" stdio "git.novox.be/novox/mesh-sdk/go" ) func main() { + if len(os.Args) > 1 { + if os.Args[1] != "draw" || len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: power [draw]") + os.Exit(2) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) + defer stop() + if err := RunDraw(ctx, Here()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } 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) + // And the power drawn, from the module's own sampler onto the bus (novox/hq ADR 0260). + go ShareDraw(m) if err := stdio.Serve("", Tools(m, w)); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/modules/power/cmd/power/publish.go b/modules/power/cmd/power/publish.go new file mode 100644 index 00000000..601510ed --- /dev/null +++ b/modules/power/cmd/power/publish.go @@ -0,0 +1,76 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// The draw on the bus (novox/hq ADR 0260). The module's bundle runs as the operator's account and so +// cannot read the energy counter; its root process can and cannot reach the bus. So the process leaves +// each reading in the module's own file and the bundle puts it in the state `draw`, keyed by this +// machine: the current value, which a machine reading later sees at once. Not an event: a reading is +// replaced every five seconds and nobody needs the ones before, and an event would keep each in a stream. +// Nothing measured is not put; a value older than its lifetime (`ttl-seconds` in the manifest) is gone. + +// DrawState is the state the draw is kept in, and DrawStale how old a reading may be and still be put. +const ( + DrawState = "draw" + DrawStale = 30 * time.Second +) + +// Putter puts one key of a state; the SDK's in the bundle, a recorder in a test. +type Putter func(key string, value any) error + +// shareDraw reads the sampler's reading and puts it for this machine, if it is fresh and measured +// something. It answers what it did, for the log. +func (m *Machine) shareDraw(machine string, now time.Time, put Putter) (string, error) { + raw, err := os.ReadFile(m.path(DrawFile)) + if err != nil { + return "no reading yet", nil + } + var r Reading + if err := json.Unmarshal(raw, &r); err != nil { + return "", fmt.Errorf("the sampler's reading is not one: %w", err) + } + at, err := time.Parse(time.RFC3339, r.At) + if err != nil || now.Sub(at) > DrawStale { + return "the sampler's reading is stale", nil + } + if r.Measures == "" { + return "nothing measured", nil + } + if err := put(machine, r); err != nil { + return "", err + } + return "put " + r.Text, nil +} + +// ShareDraw puts the draw every SampleEvery for as long as the bundle runs. A machine with no name +// from the runtime puts nothing, and says so once. +func ShareDraw(m *Machine) { + machine := os.Getenv("MESH_NODE") + if machine == "" { + fmt.Fprintln(os.Stderr, "power: the runtime gave no machine name (MESH_NODE); the draw is not shared") + return + } + put := func(key string, value any) error { + _, err := stdio.State(DrawState).Put(key, value) + return err + } + last := "" + for range time.Tick(SampleEvery) { + said, err := m.shareDraw(machine, time.Now(), put) + if err != nil { + said = err.Error() + } + if said != last && (err != nil || !strings.HasPrefix(said, "put ")) { + fmt.Fprintln(os.Stderr, "power: draw:", said) + } + last = said + } +} diff --git a/modules/power/cmd/power/publish_test.go b/modules/power/cmd/power/publish_test.go new file mode 100644 index 00000000..a162aa68 --- /dev/null +++ b/modules/power/cmd/power/publish_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "errors" + "strings" + "testing" + "time" +) + +// Defends novox/hq ADR 0260: the draw reaches the bar over the bus, as this module's state keyed by the +// machine, and only a fresh reading that measured something is put. + +func TestAFreshReadingIsPutForThisMachine(t *testing.T) { + m := tree(t, nil) + now := time.Date(2026, 10, 8, 12, 0, 10, 0, time.UTC) + w := 18.2 + if err := m.Publish(Draw{Machine: &w}.Reading(now.Add(-5 * time.Second))); err != nil { + t.Fatal(err) + } + var key string + var value Reading + said, err := m.shareDraw("laptop", now, func(k string, v any) error { key, value = k, v.(Reading); return nil }) + if err != nil || key != "laptop" || value.Text != "18.2 W machine" || value.Measures != "machine" || + !strings.HasPrefix(said, "put ") { + t.Fatalf("%q %v: put %q %+v", said, err, key, value) + } +} + +func TestAStaleOrEmptyReadingIsNotPut(t *testing.T) { + m := tree(t, nil) + now := time.Date(2026, 10, 8, 12, 1, 0, 0, time.UTC) + never := func(string, any) error { t.Fatal("put"); return nil } + if said, _ := m.shareDraw("laptop", now, never); said != "no reading yet" { + t.Fatalf("%q", said) + } + w := 9.0 + if err := m.Publish(Draw{CPU: &w}.Reading(now.Add(-time.Minute))); err != nil { + t.Fatal(err) + } + if said, _ := m.shareDraw("laptop", now, never); said != "the sampler's reading is stale" { + t.Fatalf("%q", said) + } + if err := m.Publish(Draw{}.Reading(now)); err != nil { + t.Fatal(err) + } + if said, _ := m.shareDraw("laptop", now, never); said != "nothing measured" { + t.Fatalf("%q", said) + } +} + +func TestARefusedPutIsSaid(t *testing.T) { + m := tree(t, nil) + now := time.Now() + w := 9.0 + if err := m.Publish(Draw{CPU: &w}.Reading(now)); err != nil { + t.Fatal(err) + } + if _, err := m.shareDraw("laptop", now, func(string, any) error { return errors.New("refused") }); err == nil { + t.Fatal("a refused put was taken for one done") + } +} diff --git a/modules/power/module.json b/modules/power/module.json index 12ac72d8..6411982c 100644 --- a/modules/power/module.json +++ b/modules/power/module.json @@ -20,6 +20,13 @@ "on-battery", "battery-low" ], + "state": [ + { + "name": "draw", + "ttl-seconds": 30, + "per-machine": true + } + ], "tools": [ "power_state", "power_hooks", @@ -234,6 +241,25 @@ "unit": "mesh-power-before-shutdown.service", "state": "running", "boot": "enabled" + }, + { + "id": "draw-dir", + "type": "directory", + "path": "/run/mesh-power", + "mode": "0755" + }, + { + "id": "draw", + "type": "process", + "name": "mesh-power-draw", + "artifact": "tools-go", + "run": [ + "./power", + "draw" + ], + "health": { + "kind": "unit" + } } ], "build": { @@ -255,5 +281,32 @@ "from": "files" } ] - } + }, + "contributions": [ + { + "seat": "node-bar", + "kind": "block", + "if-capability": "battery", + "data": { + "bar": "bottom", + "place": "status", + "order": 40, + "shows": "battery" + } + }, + { + "seat": "node-bar", + "kind": "block", + "if-capability": "power-meter", + "data": { + "bar": "bottom", + "place": "status", + "order": 45, + "shows": "state", + "options": { + "state": "power.draw" + } + } + } + ] }