From 76b63744e604fe1ff9870d8800e5d0dd85f8a193 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 15:22:58 +0200 Subject: [PATCH] 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. --- checks/paths/go.mod | 3 + checks/paths/main.go | 134 ++++++++++++++++++ checks/paths/main_test.go | 63 ++++++++ merge-check.sh | 3 + modules/i3status-rust/README.md | 8 ++ .../cmd/i3status-rust-tools/blocks_test.go | 24 +++- .../cmd/i3status-rust-tools/main.go | 2 + .../cmd/i3status-rust-tools/manifest_test.go | 1 + .../cmd/i3status-rust-tools/values.go | 118 +++++++++++++++ .../cmd/i3status-rust-tools/values_test.go | 92 ++++++++++++ .../i3status-rust/files/bin/i3status-value | 13 ++ modules/i3status-rust/files/block.tmpl | 8 +- modules/i3status-rust/module.json | 10 +- modules/power/README.md | 38 +++-- modules/power/cmd/power/draw.go | 46 +++++- modules/power/cmd/power/draw_test.go | 39 +---- modules/power/cmd/power/main.go | 2 + modules/power/cmd/power/publish.go | 76 ++++++++++ modules/power/cmd/power/publish_test.go | 61 ++++++++ modules/power/files/bin/power-draw | 12 -- modules/power/module.json | 17 ++- 21 files changed, 697 insertions(+), 73 deletions(-) create mode 100644 checks/paths/go.mod create mode 100644 checks/paths/main.go create mode 100644 checks/paths/main_test.go create mode 100644 modules/i3status-rust/cmd/i3status-rust-tools/values.go create mode 100644 modules/i3status-rust/cmd/i3status-rust-tools/values_test.go create mode 100755 modules/i3status-rust/files/bin/i3status-value create mode 100644 modules/power/cmd/power/publish.go create mode 100644 modules/power/cmd/power/publish_test.go delete mode 100755 modules/power/files/bin/power-draw diff --git a/checks/paths/go.mod b/checks/paths/go.mod new file mode 100644 index 00000000..bb495055 --- /dev/null +++ b/checks/paths/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/paths + +go 1.22 diff --git a/checks/paths/main.go b/checks/paths/main.go new file mode 100644 index 00000000..e0951a16 --- /dev/null +++ b/checks/paths/main.go @@ -0,0 +1,134 @@ +// Command paths holds the catalogue's modules to talking over the bus (novox/hq ADR 0260): no module +// names a path under /run or /var/lib that another module's manifest places. Such a path is that +// module's state on its machine, and a second module reading or writing it is two modules talking +// through a file, which the bus exists to replace. +// +// A module owns every `path` of its resources under /run/ or /var/lib/. Every other module's manifest +// and every file of its own except its documentation and its tests is searched for each, as a whole +// path: followed by nothing, a slash, or a character no path takes. +// +// go run . +// +// Exits 1 on a finding. +package main + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// stateRoots are where a machine keeps a module's state. +var stateRoots = []string{"/run/", "/var/lib/"} + +type manifest struct { + Module string `json:"module"` + Resources []map[string]any `json:"resources"` +} + +// Finding is one module naming another's path. +type Finding struct { + Module, File, Path, Owner string +} + +func (f Finding) String() string { + return fmt.Sprintf("%s names %s in %s, and the path is %s's; modules talk over the bus (novox/hq ADR 0260)", + f.Module, f.Path, f.File, f.Owner) +} + +// Check is every finding in the catalogue at root. +func Check(root string) ([]Finding, error) { + dirs, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return nil, err + } + owner := map[string]string{} + modules := map[string]string{} + for _, f := range dirs { + raw, err := os.ReadFile(f) + if err != nil { + return nil, err + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("%s: %w", f, err) + } + modules[m.Module] = filepath.Dir(f) + for _, r := range m.Resources { + p, _ := r["path"].(string) + for _, root := range stateRoots { + if strings.HasPrefix(p, root) && len(p) > len(root) { + owner[strings.TrimRight(p, "/")] = m.Module + } + } + } + } + paths := make([]string, 0, len(owner)) + for p := range owner { + paths = append(paths, p) + } + sort.Strings(paths) + patterns := map[string]*regexp.Regexp{} + for _, p := range paths { + patterns[p] = regexp.MustCompile(regexp.QuoteMeta(p) + `(?:$|[^A-Za-z0-9._-])`) + } + var out []Finding + names := make([]string, 0, len(modules)) + for n := range modules { + names = append(names, n) + } + sort.Strings(names) + for _, module := range names { + dir := modules[module] + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + name := d.Name() + if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, "_test.go") || + name == "go.sum" || name == "package-lock.json" { + return nil + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + text := string(raw) + for _, p := range paths { + if owner[p] != module && patterns[p].MatchString(text) { + rel, _ := filepath.Rel(root, path) + out = append(out, Finding{Module: module, File: rel, Path: p, Owner: owner[p]}) + } + } + return nil + }) + if err != nil { + return nil, err + } + } + return out, nil +} + +func main() { + root := "." + if len(os.Args) > 1 { + root = os.Args[1] + } + found, err := Check(root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + for _, f := range found { + fmt.Println(f) + } + if len(found) > 0 { + os.Exit(1) + } + fmt.Println("paths: no module names another's state on its machine") +} diff --git a/checks/paths/main_test.go b/checks/paths/main_test.go new file mode 100644 index 00000000..6c24540e --- /dev/null +++ b/checks/paths/main_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func catalogue(t *testing.T, files map[string]string) string { + t.Helper() + root := t.TempDir() + for p, c := range files { + full := filepath.Join(root, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(c), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +const owner = `{"module":"power","resources":[{"id":"d","type":"directory","path":"/run/mesh-power"}]}` + +func TestAModuleReadingAnothersStateIsFound(t *testing.T) { + root := catalogue(t, map[string]string{ + "modules/power/module.json": owner, + "modules/power/cmd/power/draw.go": `const f = "/run/mesh-power/draw"`, + "modules/bar/module.json": `{"module":"bar","resources":[{"id":"x","type":"file","path":"/etc/x","content":"cat /run/mesh-power/draw"}]}`, + "modules/other/module.json": `{"module":"other"}`, + "modules/other/files/bin/read": "#!/bin/sh\ncat /run/mesh-power\n", + "modules/other/README.md": "it once read /run/mesh-power/draw", + "modules/other/cmd/x/x_test.go": `"/run/mesh-power"`, + "modules/near/module.json": `{"module":"near","resources":[{"id":"y","type":"file","path":"/etc/y","content":"/run/mesh-power-other/x"}]}`, + }) + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + var got []string + for _, f := range found { + got = append(got, f.Module+" "+f.File) + } + want := []string{"bar modules/bar/module.json", "other modules/other/files/bin/read"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("found %v, want %v", got, want) + } + if !strings.Contains(found[0].String(), "power's") { + t.Fatalf("%s", found[0]) + } +} + +func TestACatalogueWhereEachKeepsToItsOwnIsClean(t *testing.T) { + root := catalogue(t, map[string]string{ + "modules/power/module.json": owner, + "modules/power/cmd/power/draw.go": `const f = "/run/mesh-power/draw"`, + }) + if found, err := Check(root); err != nil || len(found) != 0 { + t.Fatalf("%v %v", found, err) + } +} diff --git a/merge-check.sh b/merge-check.sh index 00d094d7..8a6eff54 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -19,9 +19,12 @@ # retired-words, in any text a module's tools can show an agent — every string in its own code that is # not a test, and every description in its manifest. Run over every module, not only the touched ones, # so a word newly retired is found everywhere it stands. The check's own test runs first. +# 4. the paths (novox/hq ADR 0260): no module names a path under /run or /var/lib that another module's +# manifest places — modules talk over the bus, not through each other's files. Over every module. set -eu (cd checks/words && go test -count=1 . && go run . ../..) +(cd checks/paths && go test -count=1 . && go run . ../..) if [ -n "${MESH_GATE:-}" ]; then checked=$("$MESH_GATE" module check modules/*/module.json) || { printf '%s\n' "$checked"; exit 1; } diff --git a/modules/i3status-rust/README.md b/modules/i3status-rust/README.md index ad706a85..49ec55d3 100644 --- a/modules/i3status-rust/README.md +++ b/modules/i3status-rust/README.md @@ -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: diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go index 83fc876b..8314ce40 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go @@ -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) + } +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/main.go b/modules/i3status-rust/cmd/i3status-rust-tools/main.go index 90918c19..ca8242f6 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/main.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/main.go @@ -11,6 +11,8 @@ import ( ) func main() { + // The values other modules keep on the bus for their blocks (novox/hq ADR 0260). + go FollowValues() if err := stdio.Serve("", tools()); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go index a39b1d72..ec41d8db 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go @@ -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) } diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/values.go b/modules/i3status-rust/cmd/i3status-rust-tools/values.go new file mode 100644 index 00000000..8ea8e2a7 --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/values.go @@ -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 `. +// 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) + } +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go new file mode 100644 index 00000000..e7ab28d2 --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go @@ -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) + } +} diff --git a/modules/i3status-rust/files/bin/i3status-value b/modules/i3status-rust/files/bin/i3status-value new file mode 100755 index 00000000..8fea95e4 --- /dev/null +++ b/modules/i3status-rust/files/bin/i3status-value @@ -0,0 +1,13 @@ +#!/bin/sh +# i3status-value (module i3status-rust, novox/hq ADR 0260): one value another module keeps +# on the bus, for a block it contributed. +# +# This module's own tools bundle watches the state on the bus (the mesh grants it the read) and leaves +# the value's text in this module's own directory; this prints it. A value not refreshed for thirty +# seconds is one its module stopped giving, and prints nothing, so the block hides rather than show an +# old number as if it were now. +dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/i3status-rust/values" +f="$dir/$1@$2" +if [ -r "$f" ] && [ $(( $(date +%s) - $(stat -c %Y "$f") )) -le 30 ]; then + head -n 1 "$f" +fi diff --git a/modules/i3status-rust/files/block.tmpl b/modules/i3status-rust/files/block.tmpl index befa9c22..527a6426 100644 --- a/modules/i3status-rust/files/block.tmpl +++ b/modules/i3status-rust/files/block.tmpl @@ -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}} diff --git a/modules/i3status-rust/module.json b/modules/i3status-rust/module.json index d6a5706f..373338f4 100644 --- a/modules/i3status-rust/module.json +++ b/modules/i3status-rust/module.json @@ -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 (module i3status-rust, novox/hq ADR 0260): one value another module keeps\n# on the bus, for a block it contributed.\n#\n# This module's own tools bundle watches the state on the bus (the mesh grants it the read) and leaves\n# the value's text in this module's own directory; this prints it. A value not refreshed for thirty\n# seconds is one its module stopped giving, and prints nothing, so the block hides rather than show an\n# old number as if it were now.\ndir=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/i3status-rust/values\"\nf=\"$dir/$1@$2\"\nif [ -r \"$f\" ] && [ $(( $(date +%s) - $(stat -c %Y \"$f\") )) -le 30 ]; then\n\thead -n 1 \"$f\"\nfi\n" } ], "build": { diff --git a/modules/power/README.md b/modules/power/README.md index 3973605b..22a01b2e 100644 --- a/modules/power/README.md +++ b/modules/power/README.md @@ -13,7 +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) | +| `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 diff --git a/modules/power/cmd/power/draw.go b/modules/power/cmd/power/draw.go index fa7206bd..eae75998 100644 --- a/modules/power/cmd/power/draw.go +++ b/modules/power/cmd/power/draw.go @@ -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) { diff --git a/modules/power/cmd/power/draw_test.go b/modules/power/cmd/power/draw_test.go index 15752271..6b06a449 100644 --- a/modules/power/cmd/power/draw_test.go +++ b/modules/power/cmd/power/draw_test.go @@ -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) - } -} diff --git a/modules/power/cmd/power/main.go b/modules/power/cmd/power/main.go index ac1d55d0..972a39fa 100644 --- a/modules/power/cmd/power/main.go +++ b/modules/power/cmd/power/main.go @@ -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) diff --git a/modules/power/cmd/power/publish.go b/modules/power/cmd/power/publish.go new file mode 100644 index 00000000..601510ed --- /dev/null +++ b/modules/power/cmd/power/publish.go @@ -0,0 +1,76 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// The draw on the bus (novox/hq ADR 0260). The module's bundle runs as the operator's account and so +// cannot read the energy counter; its root process can and cannot reach the bus. So the process leaves +// each reading in the module's own file and the bundle puts it in the state `draw`, keyed by this +// machine: the current value, which a machine reading later sees at once. Not an event: a reading is +// replaced every five seconds and nobody needs the ones before, and an event would keep each in a stream. +// Nothing measured is not put; a value older than its lifetime (`ttl-seconds` in the manifest) is gone. + +// DrawState is the state the draw is kept in, and DrawStale how old a reading may be and still be put. +const ( + DrawState = "draw" + DrawStale = 30 * time.Second +) + +// Putter puts one key of a state; the SDK's in the bundle, a recorder in a test. +type Putter func(key string, value any) error + +// shareDraw reads the sampler's reading and puts it for this machine, if it is fresh and measured +// something. It answers what it did, for the log. +func (m *Machine) shareDraw(machine string, now time.Time, put Putter) (string, error) { + raw, err := os.ReadFile(m.path(DrawFile)) + if err != nil { + return "no reading yet", nil + } + var r Reading + if err := json.Unmarshal(raw, &r); err != nil { + return "", fmt.Errorf("the sampler's reading is not one: %w", err) + } + at, err := time.Parse(time.RFC3339, r.At) + if err != nil || now.Sub(at) > DrawStale { + return "the sampler's reading is stale", nil + } + if r.Measures == "" { + return "nothing measured", nil + } + if err := put(machine, r); err != nil { + return "", err + } + return "put " + r.Text, nil +} + +// ShareDraw puts the draw every SampleEvery for as long as the bundle runs. A machine with no name +// from the runtime puts nothing, and says so once. +func ShareDraw(m *Machine) { + machine := os.Getenv("MESH_NODE") + if machine == "" { + fmt.Fprintln(os.Stderr, "power: the runtime gave no machine name (MESH_NODE); the draw is not shared") + return + } + put := func(key string, value any) error { + _, err := stdio.State(DrawState).Put(key, value) + return err + } + last := "" + for range time.Tick(SampleEvery) { + said, err := m.shareDraw(machine, time.Now(), put) + if err != nil { + said = err.Error() + } + if said != last && (err != nil || !strings.HasPrefix(said, "put ")) { + fmt.Fprintln(os.Stderr, "power: draw:", said) + } + last = said + } +} diff --git a/modules/power/cmd/power/publish_test.go b/modules/power/cmd/power/publish_test.go new file mode 100644 index 00000000..a162aa68 --- /dev/null +++ b/modules/power/cmd/power/publish_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "errors" + "strings" + "testing" + "time" +) + +// Defends novox/hq ADR 0260: the draw reaches the bar over the bus, as this module's state keyed by the +// machine, and only a fresh reading that measured something is put. + +func TestAFreshReadingIsPutForThisMachine(t *testing.T) { + m := tree(t, nil) + now := time.Date(2026, 10, 8, 12, 0, 10, 0, time.UTC) + w := 18.2 + if err := m.Publish(Draw{Machine: &w}.Reading(now.Add(-5 * time.Second))); err != nil { + t.Fatal(err) + } + var key string + var value Reading + said, err := m.shareDraw("laptop", now, func(k string, v any) error { key, value = k, v.(Reading); return nil }) + if err != nil || key != "laptop" || value.Text != "18.2 W machine" || value.Measures != "machine" || + !strings.HasPrefix(said, "put ") { + t.Fatalf("%q %v: put %q %+v", said, err, key, value) + } +} + +func TestAStaleOrEmptyReadingIsNotPut(t *testing.T) { + m := tree(t, nil) + now := time.Date(2026, 10, 8, 12, 1, 0, 0, time.UTC) + never := func(string, any) error { t.Fatal("put"); return nil } + if said, _ := m.shareDraw("laptop", now, never); said != "no reading yet" { + t.Fatalf("%q", said) + } + w := 9.0 + if err := m.Publish(Draw{CPU: &w}.Reading(now.Add(-time.Minute))); err != nil { + t.Fatal(err) + } + if said, _ := m.shareDraw("laptop", now, never); said != "the sampler's reading is stale" { + t.Fatalf("%q", said) + } + if err := m.Publish(Draw{}.Reading(now)); err != nil { + t.Fatal(err) + } + if said, _ := m.shareDraw("laptop", now, never); said != "nothing measured" { + t.Fatalf("%q", said) + } +} + +func TestARefusedPutIsSaid(t *testing.T) { + m := tree(t, nil) + now := time.Now() + w := 9.0 + if err := m.Publish(Draw{CPU: &w}.Reading(now)); err != nil { + t.Fatal(err) + } + if _, err := m.shareDraw("laptop", now, func(string, any) error { return errors.New("refused") }); err == nil { + t.Fatal("a refused put was taken for one done") + } +} diff --git a/modules/power/files/bin/power-draw b/modules/power/files/bin/power-draw deleted file mode 100755 index 6d565083..00000000 --- a/modules/power/files/bin/power-draw +++ /dev/null @@ -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 diff --git a/modules/power/module.json b/modules/power/module.json index 794c857b..faa32384 100644 --- a/modules/power/module.json +++ b/modules/power/module.json @@ -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" } } }