Merge pull request 'The bar renders contributed blocks; the power module gives it the battery and power draw (hq ADR 0255)' (#127) from feat/the-bar-takes-blocks into main

This commit was merged in pull request #127.
This commit is contained in:
2026-10-08 14:27:18 +00:00
27 changed files with 1998 additions and 28 deletions
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/paths
go 1.22
+6
View File
@@ -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: <module> <path> <file> # 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
+278
View File
@@ -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/<unit>.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 <dir>` 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 . <catalogue root>
//
// 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))
}
+135
View File
@@ -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)
}
}
+3
View File
@@ -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
+14
View File
@@ -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; }
+6 -4
View File
@@ -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) |
@@ -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)
+36 -16
View File
@@ -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
@@ -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(), "<no value>") {
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)
}
}
@@ -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)
@@ -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 {
@@ -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)
}
@@ -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 <state> <key>`.
// 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)
}
}
@@ -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)
}
}
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# i3status-value <state> <key> (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
+26
View File
@@ -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}}
+12 -3
View File
@@ -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"
+4
View File
@@ -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}
+14 -3
View File
@@ -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 <state> <key> (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": {
+47
View File
@@ -13,6 +13,7 @@ states on the bus.
| `/etc/mesh-power/moments/<moment>` | for each moment, every module's code as the controller placed it (`${shell:<moment>:<slot>}`) |
| `/usr/local/lib/mesh-power/bin/power-moment` | the runner: each module's piece on its own, as root, with `sh`, bounded (`MESH_POWER_BOUND`, default 30 s); every outcome in the journal under `mesh-power` |
| five units | `mesh-power-after-boot`, `-before-shutdown` (its stop is the shutdown, while the network is still up), `-before-sleep`, `-after-wake`, `-supply` |
| `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
+301
View File
@@ -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
}
+210
View File
@@ -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)
}
}
+20
View File
@@ -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)
+76
View File
@@ -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
}
}
+61
View File
@@ -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")
}
}
+54 -1
View File
@@ -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"
}
}
}
]
}