Carry the power draw to the bar over the bus, not through a file (hq ADR 0260)
Modules talk over the bus. The power module's bundle puts each fresh reading of its own sampler in its state draw, keyed by the machine; the bar's block shows that state, which the bar's bundle watches under the read the mesh grants it. A new catalogue check refuses a module naming another module's path under /run or /var/lib.
This commit is contained in:
@@ -41,6 +41,14 @@ contributes it to `node-bar` as data that says what it shows, and this module re
|
||||
- **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/`), and the block runs `~/.local/bin/i3status-value`,
|
||||
which prints it, or nothing when it is more than 30 s old, so the block hides. 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.
|
||||
|
||||
Still left out:
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
@@ -33,7 +34,7 @@ func blockTemplate(t *testing.T) *template.Template {
|
||||
|
||||
func render(t *testing.T, tmpl *template.Template, piece string) string {
|
||||
t.Helper()
|
||||
view := map[string]any{"module": "power"}
|
||||
view := map[string]any{"module": "power", "machine": "laptop"}
|
||||
if err := json.Unmarshal([]byte(piece), &view); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -96,3 +97,24 @@ func TestTheMemoryIsWhatProgramsHoldNotTheCache(t *testing.T) {
|
||||
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,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,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"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 every state the modules on its machine offer the bar. So it watches 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.
|
||||
|
||||
// valueCall is how a bar names a value: the command the template renders.
|
||||
var valueCall = regexp.MustCompile(`i3status-value ([a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*) ([^\s"]+)`)
|
||||
|
||||
// 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 change of a value to its file: the value's `text`, or no file once it is deleted.
|
||||
func keep(dir string, w Wanted, c stdio.StateChange) error {
|
||||
path := filepath.Join(dir, w.file())
|
||||
if c.Op == "delete" || c.Op == "purge" {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var v struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(c.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 := path + ".new"
|
||||
if err := os.WriteFile(tmp, []byte(v.Text+"\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// Watcher is a function that watches one key of one state; the SDK's in the bundle, a fake in a test.
|
||||
type Watcher func(state, key string, handle func(stdio.StateChange) error) error
|
||||
|
||||
// followValues watches every value the bars name, starting the ones not yet watched each time it is
|
||||
// called; one the runtime refuses — a read not granted yet — is tried again the next time.
|
||||
func followValues(files []string, dir string, watching map[Wanted]bool, watch Watcher) []string {
|
||||
var said []string
|
||||
for _, w := range wantedIn(files) {
|
||||
if watching[w] {
|
||||
continue
|
||||
}
|
||||
w := w
|
||||
err := watch(w.State, w.Key, func(c stdio.StateChange) error { return keep(dir, w, c) })
|
||||
if err != nil {
|
||||
said = append(said, fmt.Sprintf("cannot watch %s %s: %v", w.State, w.Key, err))
|
||||
continue
|
||||
}
|
||||
watching[w] = true
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
// FollowValues runs followValues every half minute for as long as the bundle runs, so a block a push
|
||||
// adds is followed without a restart.
|
||||
func FollowValues() {
|
||||
watching := map[Wanted]bool{}
|
||||
watch := func(state, key string, handle func(stdio.StateChange) error) error {
|
||||
return stdio.State(state).Watch(key, handle)
|
||||
}
|
||||
for {
|
||||
files, _ := filepath.Glob(filepath.Join(configDir(), "*.toml"))
|
||||
for _, s := range followValues(files, valuesDir(), watching, watch) {
|
||||
fmt.Fprintln(os.Stderr, "i3status-rust:", s)
|
||||
}
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0260: a value another module keeps reaches the bar over the bus, through this
|
||||
// module's own bundle and directory, and an old one is not shown as if it were now.
|
||||
|
||||
func TestAValueIsKeptAsItsTextAndGoneWhenDeleted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := Wanted{"power.draw", "laptop"}
|
||||
if err := keep(dir, w, stdio.StateChange{Op: "put", Value: []byte(`{"text":"18.2 W machine","watts":18.2}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, "power.draw@laptop"))
|
||||
if err != nil || string(b) != "18.2 W machine\n" {
|
||||
t.Fatalf("%q %v", b, err)
|
||||
}
|
||||
if err := keep(dir, w, stdio.StateChange{Op: "delete"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "power.draw@laptop")); !os.IsNotExist(err) {
|
||||
t.Fatal("a deleted value is still shown")
|
||||
}
|
||||
if err := keep(dir, w, stdio.StateChange{Op: "put", Value: []byte(`"no text"`)}); err == nil {
|
||||
t.Fatal("a value with no text was kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusedWatchIsTriedAgainAndAWatchedOneIsNot(t *testing.T) {
|
||||
bar := filepath.Join(t.TempDir(), "bottom-bar.toml")
|
||||
if err := os.WriteFile(bar, []byte(`command = "~/.local/bin/i3status-value power.draw laptop"`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
watching := map[Wanted]bool{}
|
||||
calls := 0
|
||||
refuse := func(string, string, func(stdio.StateChange) error) error { calls++; return errors.New("not granted") }
|
||||
if said := followValues([]string{bar}, t.TempDir(), watching, refuse); len(said) != 1 || !strings.Contains(said[0], "not granted") {
|
||||
t.Fatalf("%v", said)
|
||||
}
|
||||
accept := func(state, key string, _ func(stdio.StateChange) error) error {
|
||||
calls++
|
||||
if state != "power.draw" || key != "laptop" {
|
||||
t.Fatalf("watched %s %s", state, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
followValues([]string{bar}, t.TempDir(), watching, accept)
|
||||
followValues([]string{bar}, t.TempDir(), watching, accept)
|
||||
if calls != 2 {
|
||||
t.Fatalf("%d watches; once refused, once taken, then left alone", calls)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if err := keep(dir, Wanted{"power.draw", "laptop"}, stdio.StateChange{Op: "put", Value: []byte(`{"text":"8.0 W CPU"}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := run(); got != "8.0 W CPU" {
|
||||
t.Fatalf("fresh: %q", got)
|
||||
}
|
||||
old := time.Now().Add(-31 * time.Second)
|
||||
if err := os.Chtimes(filepath.Join(dir, "power.draw@laptop"), old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := run(); got != "" {
|
||||
t.Fatalf("31 s old: %q", got)
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
{{- /* 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. */ -}}
|
||||
renders one piece at a time: its fields (bar, place, order, shows, options), module and machine. */ -}}
|
||||
{{- if eq .shows "battery" -}}
|
||||
[[block]]
|
||||
block = "battery"
|
||||
@@ -16,5 +16,11 @@ missing_format = ""
|
||||
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,7 +12,7 @@
|
||||
"name": "node-bar",
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -88,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": {
|
||||
|
||||
+24
-14
@@ -13,7 +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 and publishes it to `/run/mesh-power/draw` for the bar (below) |
|
||||
| `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
|
||||
@@ -32,7 +32,7 @@ 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 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
|
||||
@@ -40,8 +40,8 @@ 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` |
|
||||
| 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:
|
||||
|
||||
@@ -51,16 +51,26 @@ The power draw says what it measured:
|
||||
`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 only while it is awake; an NVIDIA device through `nvidia-smi` only
|
||||
while it is awake, so the bar never wakes a sleeping GPU. 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.
|
||||
**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`).
|
||||
|
||||
**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
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -21,10 +22,12 @@ import (
|
||||
//
|
||||
// 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.
|
||||
// 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 rate is published, and SampleEvery how often.
|
||||
// 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
|
||||
@@ -40,6 +43,30 @@ type Draw struct {
|
||||
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 {
|
||||
@@ -221,8 +248,13 @@ func (m *Machine) Sample(ctx context.Context, every time.Duration, wait func(tim
|
||||
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 {
|
||||
// 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
|
||||
@@ -244,7 +276,7 @@ func RunDraw(ctx context.Context, m *Machine) error {
|
||||
|
||||
// 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(string) error) error {
|
||||
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():
|
||||
@@ -258,7 +290,7 @@ func runDraw(ctx context.Context, m *Machine, every time.Duration, publish func(
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if err := publish(d.Line()); err != nil {
|
||||
if err := publish(d.Reading(time.Now())); err != nil {
|
||||
return fmt.Errorf("publishing the draw: %w", err)
|
||||
}
|
||||
if d.Machine != nil && !wait(every) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -124,9 +123,10 @@ func TestNothingToMeasureIsSaid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyTheRateIsPublishedReadableByAnyone(t *testing.T) {
|
||||
func TestOnlyTheRateIsLeftForTheBundle(t *testing.T) {
|
||||
m := tree(t, nil)
|
||||
if err := m.Publish("8.0 W CPU"); err != 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))
|
||||
@@ -134,7 +134,7 @@ func TestOnlyTheRateIsPublishedReadableByAnyone(t *testing.T) {
|
||||
t.Fatalf("%v %v", info, err)
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(m.Root, DrawFile))
|
||||
if string(b) != "8.0 W CPU\n" {
|
||||
if string(b) != `{"text":"8.0 W CPU","watts":8,"measures":"cpu","at":"2026-10-08T12:00:00Z"}`+"\n" {
|
||||
t.Fatalf("%q", b)
|
||||
}
|
||||
}
|
||||
@@ -191,7 +191,7 @@ func TestTheLoopPublishesEveryIntervalAndStopsWhenCancelled(t *testing.T) {
|
||||
var lines []string
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
done <- runDraw(ctx, m, 20*time.Millisecond, func(l string) error { lines = append(lines, l); return nil })
|
||||
done <- runDraw(ctx, m, 20*time.Millisecond, func(r Reading) error { lines = append(lines, r.Text); return nil })
|
||||
}()
|
||||
time.Sleep(210 * time.Millisecond)
|
||||
cancel()
|
||||
@@ -208,32 +208,3 @@ func TestTheLoopPublishesEveryIntervalAndStopsWhenCancelled(t *testing.T) {
|
||||
t.Fatalf("%d lines: %v", len(lines), lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBarsScriptShowsOnlyAFreshLine(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "draw")
|
||||
run := func() string {
|
||||
cmd := exec.Command("sh", "../../files/bin/power-draw")
|
||||
cmd.Env = append(os.Environ(), "MESH_POWER_DRAW="+file)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
if got := run(); got != "power not measured" {
|
||||
t.Fatalf("no file: %q", got)
|
||||
}
|
||||
if err := os.WriteFile(file, []byte("8.0 W CPU\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := run(); got != "8.0 W CPU" {
|
||||
t.Fatalf("fresh: %q", got)
|
||||
}
|
||||
old := time.Now().Add(-31 * time.Second)
|
||||
if err := os.Chtimes(file, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := run(); got != "power not measured" {
|
||||
t.Fatalf("31 s old: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ func main() {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/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="${MESH_POWER_DRAW:-/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
|
||||
@@ -20,6 +20,12 @@
|
||||
"on-battery",
|
||||
"battery-low"
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
"name": "draw",
|
||||
"ttl-seconds": 30
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"power_state",
|
||||
"power_hooks",
|
||||
@@ -235,6 +241,12 @@
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
},
|
||||
{
|
||||
"id": "draw-dir",
|
||||
"type": "directory",
|
||||
"path": "/run/mesh-power",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "draw",
|
||||
"type": "process",
|
||||
@@ -289,10 +301,9 @@
|
||||
"bar": "bottom",
|
||||
"place": "status",
|
||||
"order": 45,
|
||||
"shows": "command",
|
||||
"shows": "state",
|
||||
"options": {
|
||||
"command": "/usr/local/lib/mesh-power/bin/power-draw",
|
||||
"interval": 5
|
||||
"state": "power.draw"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user