diff --git a/modules/asus-zephyrus-g14/README.md b/modules/asus-zephyrus-g14/README.md index 2d6f69ba..05efa63c 100644 --- a/modules/asus-zephyrus-g14/README.md +++ b/modules/asus-zephyrus-g14/README.md @@ -58,9 +58,11 @@ daemons and probably the same keys. Their GPU options are unverified. handling. Until then it stays as found, and it still calls the predecessor's `as-user`. - **The screenshot script** (`~/.config/i3/scripts/screenshot.sh`) is any machine's. The `i3` module binds it too. This module only binds the key the firmware sends for it. -- **The bar's battery block.** It belongs here (a block that follows this model's hardware), but the - bar has no way in yet (`i3status-rust` README). Once ADR 0210's contributions reach the bar seat, - this module contributes it. +- **The bar's battery block, and the power draw's.** A battery is any laptop's, not this model's, so + the `power` module contributes both blocks wherever the machine reports a battery or a power meter + (novox/hq ADR 0255). This module would contribute only what is this model's alone, such as the charge + limit asusd holds; nothing yet. The predecessor's AMD GPU block is no model's either, and waits for a + generic module (the `i3status-rust` README). **Requires `x11-display`.** The i3 lines and the session scripts need a display, so the module is assigned where the display server is. @@ -306,7 +308,7 @@ The predecessor carried this model in its desktop module's `g14` and `laptop` fl | `g14-90-backlight.rules` | **this module**, the same path | | `g14-logind-brightness.conf` | **retired**: an unknown logind key that did nothing | | `laptop-monitor-wizard.sh`, `laptop-91-monitor-hotplug.rules` | **any laptop's, not this module's**: kept as found, for the `xorg` module's autorandr to replace | -| `bottom-bar.g14.toml` (the battery block) | **waits**: this module's, once the bar takes contributions (ADR 0210) | +| `bottom-bar.g14.toml` (the battery block) | **the `power` module's**: any machine with a battery gets it (ADR 0255) | | `razer-basilisk-battery-percentage` | **not this model's**: a mouse. No module carries it (`i3status-rust` README) | | `screenshot.sh` | **not this model's**: any machine's script, which the `i3` module binds too; this module binds Fn+F6 to it | | package `triggerhappy` | **depended on, kept as found** (outside the distribution, above) | diff --git a/modules/i3status-rust/README.md b/modules/i3status-rust/README.md index 70a19c75..ad706a85 100644 --- a/modules/i3status-rust/README.md +++ b/modules/i3status-rust/README.md @@ -3,7 +3,8 @@ The bars as a module (novox/hq ADR 0208, research 026/05). - Installs `i3status-rust`. The update block's `checkupdates` comes from `pacman-contrib`, which the `pacman` module installs on every node; two modules declaring one package is refused at composition. - Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2). Requires `x11-display` on its own + Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2) and renders the blocks other modules + contribute to it (ADR 0255, below). Requires `x11-display` on its own machine: its bars are i3bar's. - Owns `~/.config/i3status-rust/`, both bars (`top-bar.toml`, `bottom-bar.toml`) and their icon set (`icons/custom-icons.toml`). @@ -25,25 +26,30 @@ 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. -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 +59,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 +88,6 @@ Until one of them is chosen, **the laptop's bar shows no battery** once this mod - `node-bar`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them, `mctl` reads them as unknown. -- The battery block waits for one of the two ways above. - The watchdog would be a user unit once user-scoped units ship (mesh-host #72). It is not, because it runs per session and ends with the session, as a session-start line already does. - `pacman-contrib` is the `pacman` module's, which took it over as foreseen: a package is declared diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go new file mode 100644 index 00000000..83fc876b --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "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"} + if err := json.Unmarshal([]byte(piece), &view); err != nil { + t.Fatal(err) + } + var b bytes.Buffer + if err := tmpl.Execute(&b, view); err != nil { + t.Fatal(err) + } + if strings.Contains(b.String(), "") { + t.Fatalf("%s rendered a missing value: %q", piece, b.String()) + } + return b.String() +} + +func TestAContributedBatteryIsI3statusRustsBatteryBlock(t *testing.T) { + tmpl := blockTemplate(t) + got := render(t, tmpl, `{"bar":"bottom","place":"status","shows":"battery"}`) + if !strings.HasPrefix(got, "[[block]]\nblock = \"battery\"\nformat = ") || strings.Contains(got, "device") || + !strings.Contains(got, `missing_format = ""`) || !strings.HasSuffix(got, "\n\n") { + t.Fatalf("%q", got) + } + got = render(t, tmpl, `{"bar":"bottom","place":"status","shows":"battery","options":{"device":"BAT1"}}`) + if !strings.Contains(got, "block = \"battery\"\ndevice = \"BAT1\"\nformat = ") { + t.Fatalf("%q", got) + } +} + +func TestAContributedCommandIsACustomBlockQuoted(t *testing.T) { + got := render(t, blockTemplate(t), + `{"bar":"bottom","place":"status","shows":"command","options":{"command":"say \"hi\"","interval":5}}`) + want := "[[block]]\nblock = \"custom\"\ncommand = \"say \\\"hi\\\"\"\ninterval = 5\n\n" + if got != want { + t.Fatalf("%q, want %q", got, want) + } +} + +func TestEachBarPlacesBothPlacesOnce(t *testing.T) { + m := readManifest(t) + for _, bar := range []string{"bottom", "top"} { + content := m.resource(t, bar+"-bar")["content"].(string) + for _, place := range []string{"resources", "status"} { + ph := "${contribution:node-bar:block:bar=" + bar + ",place=" + place + "}" + if strings.Count(content, ph) != 1 { + t.Errorf("the %s bar places %s %d times", bar, ph, strings.Count(content, ph)) + } + } + } + bottom := m.resource(t, "bottom-bar")["content"].(string) + resources := strings.Index(bottom, "place=resources") + if !(strings.Index(bottom, `block = "memory"`) < resources && resources < strings.Index(bottom, `block = "docker"`)) || + !(strings.Index(bottom, `block = "sound"`) < strings.Index(bottom, "place=status") && + strings.Index(bottom, "place=status") < strings.Index(bottom, `block = "weather"`)) { + t.Fatal("the places are not beside the memory and beside the sound") + } +} + +func TestTheMemoryIsWhatProgramsHoldNotTheCache(t *testing.T) { + bottom := readManifest(t).resource(t, "bottom-bar")["content"].(string) + if !strings.Contains(bottom, `format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"`) || + strings.Contains(bottom, "mem_total_used") { + t.Fatal("the memory block counts the cache as used") + } +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go index 441415f9..5c76cde9 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go @@ -33,6 +33,8 @@ type claim struct { Name string `json:"name"` Scope string `json:"scope"` Serves []string `json:"serves"` + // How the holder renders each kind its seat receives as data (novox/hq ADR 0255). + Renders map[string]string `json:"renders"` } type environment struct { diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go index 0acfefa2..a39b1d72 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go @@ -16,7 +16,8 @@ func TestItClaimsTheBarSeatAndRequiresTheXDisplay(t *testing.T) { if m.Module != "i3status-rust" || m.Seats != nil { t.Fatalf("module %q declares seats %v", m.Module, m.Seats) } - if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-bar", Scope: "node"}}) { + if len(m.Claims) != 1 || m.Claims[0].Name != "node-bar" || m.Claims[0].Scope != "node" || + len(m.Claims[0].Renders) != 1 || m.Claims[0].Renders["block"] == "" { t.Fatalf("claims: %+v", m.Claims) } if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) { diff --git a/modules/i3status-rust/files/block.tmpl b/modules/i3status-rust/files/block.tmpl new file mode 100644 index 00000000..befa9c22 --- /dev/null +++ b/modules/i3status-rust/files/block.tmpl @@ -0,0 +1,20 @@ +{{- /* 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) and module. */ -}} +{{- 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}} +{{- end}} + diff --git a/modules/i3status-rust/files/bottom-bar.toml b/modules/i3status-rust/files/bottom-bar.toml index 80980938..58ef2029 100644 --- a/modules/i3status-rust/files/bottom-bar.toml +++ b/modules/i3status-rust/files/bottom-bar.toml @@ -1,7 +1,8 @@ # The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every # push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one -# person's devices left out: the battery and the GPU belong to a machine model's hardware module, -# and a headset's address is not the bar's to know. Every block here works the same on any machine. +# person's devices left out: a block that depends on the hardware is contributed by the module that +# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written +# here works the same on any machine. icons_format = "{icon}" [theme] @@ -66,9 +67,14 @@ format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))" [[block]] block = "memory" -format = "$icon $mem_total_used.eng(w:2) ($mem_total_used_percents.eng(w:2))" +# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure +# module judges by. Counting the cache as used read near full on a machine with room to spare. +format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))" format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))" +# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from +# what they show by this module's template, and only on a machine that has what they show. +${contribution:node-bar:block:bar=bottom,place=resources} [[block]] block = "docker" interval = 2 @@ -80,6 +86,9 @@ block = "sound" button = "left" cmd = "pavucontrol" +# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and +# power draw, where the machine has them. +${contribution:node-bar:block:bar=bottom,place=status} # The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is. [[block]] block = "weather" diff --git a/modules/i3status-rust/files/top-bar.toml b/modules/i3status-rust/files/top-bar.toml index 11660368..b767a765 100644 --- a/modules/i3status-rust/files/top-bar.toml +++ b/modules/i3status-rust/files/top-bar.toml @@ -31,3 +31,7 @@ short = " $title.str(max_w:100) |" [[block]] block = "uptime" interval = 60 + +# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept +# so that one that does is never dropped. +${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status} diff --git a/modules/i3status-rust/module.json b/modules/i3status-rust/module.json index ad647882..d6a5706f 100644 --- a/modules/i3status-rust/module.json +++ b/modules/i3status-rust/module.json @@ -10,7 +10,10 @@ "claims": [ { "name": "node-bar", - "scope": "node" + "scope": "node", + "renders": { + "block": "{{- /* A block other modules contribute, in i3status-rust's grammar (novox/hq ADR 0255). The controller\nrenders one piece at a time: its fields (bar, place, order, shows, options) and module. */ -}}\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{{- 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", diff --git a/modules/power/README.md b/modules/power/README.md index f85d44ad..d43bb803 100644 --- a/modules/power/README.md +++ b/modules/power/README.md @@ -13,6 +13,7 @@ states on the bus. | `/etc/mesh-power/moments/` | for each moment, every module's code as the controller placed it (`${shell::}`) | | `/usr/local/lib/mesh-power/bin/power-moment` | the runner: each module's piece on its own, as root, with `sh`, bounded (`MESH_POWER_BOUND`, default 30 s); every outcome in the journal under `mesh-power` | | five units | `mesh-power-after-boot`, `-before-shutdown` (its stop is the shutdown, while the network is still up), `-before-sleep`, `-after-wake`, `-supply` | +| `mesh-power-draw` (process) | samples the power drawn and publishes it to `/run/mesh-power/draw` for the bar (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,34 @@ 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) + +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` (a Battery supply not scoped to a device) | the charge, and the time left or to full | +| power draw | `if-capability: power-meter` (a system battery, a powercap package counter, or a graphics device that reports its power) | one line from `bin/power-draw` | + +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. + +**The energy counter stays root's.** `energy_uj` is readable by root alone on purpose: read often by +anyone, it leaks what the processor is doing (the PLATYPUS attack). So no permission is loosened. The +module's process `mesh-power-draw` (`power draw`, as root) samples every 5 s and writes only the +averaged rate, one line, to `/run/mesh-power/draw` (0644). The bar's script prints that line, and says +`power not measured` when it is missing or older than 30 s. 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 an NVIDIA device through `nvidia-smi` only while it is awake, so the +bar never wakes a sleeping GPU. + ## Settings `handle-power-key`, `handle-lid-switch`, `handle-lid-switch-external-power` and diff --git a/modules/power/cmd/power/draw.go b/modules/power/cmd/power/draw.go new file mode 100644 index 00000000..280bb1cf --- /dev/null +++ b/modules/power/cmd/power/draw.go @@ -0,0 +1,244 @@ +package main + +import ( + "context" + "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, one line, +// to DrawFile — readable by anyone, and too coarse to leak anything. The bar's block prints that line. + +// DrawFile is where the rate is published, 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 +} + +// 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 +} + +// packageCounters is every processor package's energy counter, by its directory. +func (m *Machine) packageCounters() map[string]counter { + out := map[string]counter{} + for _, dir := range m.glob("/sys/class/powercap/*") { + if !strings.HasPrefix(m.read(dir+"/name"), "package") { + continue + } + uj, ok := m.readInt(dir + "/energy_uj") + if !ok { + continue + } + max, _ := m.readInt(dir + "/max_energy_range_uj") + out[dir] = counter{uj: uj, max: max} + } + 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. +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 + } + 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 + } + 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 line where the bar reads it: whole, readable by anyone, replacing the last. +func (m *Machine) Publish(line string) error { + 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. A battery reading is instant, so it waits +// the interval itself; a counter reading waits inside its sample. +func RunDraw(ctx context.Context, m *Machine) 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, SampleEvery, wait) + if ctx.Err() != nil { + break + } + if err := m.Publish(d.Line()); err != nil { + return fmt.Errorf("publishing the draw: %w", err) + } + if d.Machine != nil && !wait(SampleEvery) { + break + } + } + return nil +} diff --git a/modules/power/cmd/power/draw_test.go b/modules/power/cmd/power/draw_test.go new file mode 100644 index 00000000..fa503e01 --- /dev/null +++ b/modules/power/cmd/power/draw_test.go @@ -0,0 +1,137 @@ +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") + d := m.Sample(context.Background(), time.Second, func(time.Duration) bool { + return os.WriteFile(counter, []byte("11000000\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 TestOnlyTheRateIsPublishedReadableByAnyone(t *testing.T) { + m := tree(t, nil) + if err := m.Publish("8.0 W CPU"); 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) != "8.0 W CPU\n" { + t.Fatalf("%q", b) + } +} diff --git a/modules/power/cmd/power/main.go b/modules/power/cmd/power/main.go index 1221ae74..ac1d55d0 100644 --- a/modules/power/cmd/power/main.go +++ b/modules/power/cmd/power/main.go @@ -1,17 +1,35 @@ // 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()) diff --git a/modules/power/files/bin/power-draw b/modules/power/files/bin/power-draw new file mode 100755 index 00000000..58430300 --- /dev/null +++ b/modules/power/files/bin/power-draw @@ -0,0 +1,12 @@ +#!/bin/sh +# power-draw (module power, novox/hq ADR 0255): the power the machine draws, for the bar's block. +# +# Prints the line the module's sampler (`power draw`, as root) publishes every five seconds, which says +# what it measured: "machine" on battery, "CPU" or "CPU+GPU" otherwise. A line older than thirty +# seconds is a sampler that stopped, and is not shown as if it were now. +f=/run/mesh-power/draw +if [ -r "$f" ] && [ $(( $(date +%s) - $(stat -c %Y "$f") )) -le 30 ]; then + head -n 1 "$f" +else + echo "power not measured" +fi diff --git a/modules/power/module.json b/modules/power/module.json index 12ac72d8..794c857b 100644 --- a/modules/power/module.json +++ b/modules/power/module.json @@ -234,6 +234,19 @@ "unit": "mesh-power-before-shutdown.service", "state": "running", "boot": "enabled" + }, + { + "id": "draw", + "type": "process", + "name": "mesh-power-draw", + "artifact": "tools-go", + "run": [ + "./power", + "draw" + ], + "health": { + "kind": "unit" + } } ], "build": { @@ -255,5 +268,33 @@ "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": "command", + "options": { + "command": "/usr/local/lib/mesh-power/bin/power-draw", + "interval": 5 + } + } + } + ] }