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:
jochen
2026-10-08 16:12:37 +02:00
parent e1b7fcd8c9
commit 76b63744e6
21 changed files with 697 additions and 73 deletions
@@ -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)
}
}