From 0040c008b563a5b25ed5d83b3257d27be88ff6c0 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 13:42:47 +0200 Subject: [PATCH] Let the bar render blocks other modules contribute, and give it the battery and power draw from the power module (hq ADR 0255) The bar left the battery out because it belongs to whoever knows the hardware, and had no way to take it back in. i3status-rust now renders bar-neutral blocks through its template; the power module contributes the battery where the machine reports one and the power draw where something measures it, sampling the root-only energy counter as root and publishing only a rate. The memory block shows what programs hold, the figure memory-pressure judges by, instead of counting the cache as used. --- modules/asus-zephyrus-g14/README.md | 10 +- modules/i3status-rust/README.md | 40 +-- .../cmd/i3status-rust-tools/blocks_test.go | 98 +++++++ .../manifest_helpers_test.go | 2 + .../cmd/i3status-rust-tools/manifest_test.go | 3 +- modules/i3status-rust/files/block.tmpl | 20 ++ modules/i3status-rust/files/bottom-bar.toml | 15 +- modules/i3status-rust/files/top-bar.toml | 4 + modules/i3status-rust/module.json | 9 +- modules/power/README.md | 29 +++ modules/power/cmd/power/draw.go | 244 ++++++++++++++++++ modules/power/cmd/power/draw_test.go | 137 ++++++++++ modules/power/cmd/power/main.go | 18 ++ modules/power/files/bin/power-draw | 12 + modules/power/module.json | 43 ++- 15 files changed, 656 insertions(+), 28 deletions(-) create mode 100644 modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go create mode 100644 modules/i3status-rust/files/block.tmpl create mode 100644 modules/power/cmd/power/draw.go create mode 100644 modules/power/cmd/power/draw_test.go create mode 100755 modules/power/files/bin/power-draw 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 + } + } + } + ] }