Merge pull request 'The bar renders contributed blocks; the power module gives it the battery and power draw (hq ADR 0255)' (#127) from feat/the-bar-takes-blocks into main
This commit was merged in pull request #127.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/paths
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,6 @@
|
||||
# Findings standing when the check took /etc and /usr/local/lib in (2026-10-08). Each is a module reading
|
||||
# another's file instead of asking over the bus. The list only shrinks: a finding that no longer occurs
|
||||
# must leave it. Format: <module> <path> <file> # why it is still here
|
||||
asus-zephyrus-g14 /etc/mesh-power modules/asus-zephyrus-g14/cmd/zephyrus/check.go # its check reads the power module's moment file; should ask power_hooks
|
||||
asus-zephyrus-g14 /etc/mesh-power/moments modules/asus-zephyrus-g14/cmd/zephyrus/check.go # the same
|
||||
asus-zephyrus-g14 /etc/mesh-power/moments/after-wake modules/asus-zephyrus-g14/cmd/zephyrus/check.go # the same
|
||||
@@ -0,0 +1,278 @@
|
||||
// Command paths holds the catalogue's modules to talking over the bus (novox/hq ADR 0260): no module
|
||||
// names a path that another module's manifest places under /run, /var/lib, /usr/local/lib or /etc.
|
||||
// Such a path is that module's state or configuration 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 those roots. Every other module's manifest, and
|
||||
// every file of its own but its documentation, its tests and its binaries, is searched for each as a
|
||||
// whole path: followed by nothing, a slash, or a character no path takes. A path a container sees
|
||||
// inside itself (the target of a `volumes` entry) is the container's, and is not searched.
|
||||
//
|
||||
// **A drop-in directory is no module's.** A directory whose name ends in `.d` (`/etc/sysctl.d`,
|
||||
// `/etc/systemd/system/<unit>.d`, `/etc/X11/xorg.conf.d`) is a place several modules each put their own
|
||||
// file, so naming it is not reaching into one module's state; the files in it are each their placer's.
|
||||
//
|
||||
// Two lists beside this file narrow it, each line a path or a finding and its reason after a `#`:
|
||||
//
|
||||
// - shared: paths every program on a machine reads as the machine's, not as a message from their
|
||||
// owner — /etc/hosts, which every resolver reads.
|
||||
// - known: the findings standing on the day the roots were widened (2026-10-08). The check fails on a
|
||||
// finding not listed, and on a listed one that no longer occurs, so the list only shrinks.
|
||||
//
|
||||
// **Neither list may grow unseen.** With `-base <dir>` holding the base branch's copies of both, a line
|
||||
// the change adds is refused unless its reason says `allow:` and why — so an addition is a decision a
|
||||
// reviewer reads in the diff, not a way past the check.
|
||||
//
|
||||
// **What it cannot see:** a path built in code from pieces (`filepath.Join("/etc", name)`), one read
|
||||
// from the environment or a setting, and one reached through a shell the module runs. It reads text,
|
||||
// not what a program does; review and the runtime's grants cover the rest.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// stateRoots are where a machine keeps a module's state and configuration.
|
||||
var stateRoots = []string{"/run/", "/var/lib/", "/usr/local/lib/", "/etc/"}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Key is how a finding is listed in `known`: the module, the path and the file.
|
||||
func (f Finding) Key() string { return f.Module + " " + f.Path + " " + f.File }
|
||||
|
||||
// containerTargets drops what a container sees inside itself from a manifest before it is searched:
|
||||
// the target half of every `volumes` entry of a container resource.
|
||||
func containerTargets(m map[string]any) {
|
||||
resources, _ := m["resources"].([]any)
|
||||
for _, raw := range resources {
|
||||
r, ok := raw.(map[string]any)
|
||||
if !ok || r["type"] != "container" {
|
||||
continue
|
||||
}
|
||||
volumes, _ := r["volumes"].([]any)
|
||||
for i, v := range volumes {
|
||||
if s, ok := v.(string); ok {
|
||||
if src, _, two := strings.Cut(s, ":"); two {
|
||||
volumes[i] = src
|
||||
}
|
||||
}
|
||||
if o, ok := v.(map[string]any); ok {
|
||||
delete(o, "target")
|
||||
delete(o, "container")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check is every finding in the catalogue at root, before the lists narrow it.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
files, 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 files {
|
||||
raw, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
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)
|
||||
if r["type"] == "directory" && strings.HasSuffix(filepath.Base(p), ".d") {
|
||||
continue // a drop-in directory: several modules' place, no one module's
|
||||
}
|
||||
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._-])`)
|
||||
}
|
||||
names := make([]string, 0, len(modules))
|
||||
for n := range modules {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var out []Finding
|
||||
for _, module := range names {
|
||||
err := filepath.WalkDir(modules[module], 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
|
||||
}
|
||||
if bytes.IndexByte(raw, 0) >= 0 {
|
||||
return nil // a binary: what it says is in its source
|
||||
}
|
||||
if name == "module.json" {
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) == nil {
|
||||
containerTargets(m)
|
||||
raw, _ = json.Marshal(m)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// list reads one of the lists beside this check: each line's entry before a `#`, trimmed, with its
|
||||
// reason after it.
|
||||
func list(path string) (map[string]bool, map[string]string, error) {
|
||||
out, why := map[string]bool{}, map[string]string{}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
line, reason, _ := strings.Cut(s.Text(), "#")
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
out[line] = true
|
||||
why[line] = strings.TrimSpace(reason)
|
||||
}
|
||||
}
|
||||
return out, why, s.Err()
|
||||
}
|
||||
|
||||
// Grown is every entry a list has that its base copy does not, and whose reason does not say `allow:`.
|
||||
func Grown(name string, now map[string]bool, why map[string]string, base map[string]bool) []string {
|
||||
var out []string
|
||||
for entry := range now {
|
||||
if !base[entry] && !strings.Contains(why[entry], "allow:") {
|
||||
out = append(out, fmt.Sprintf("checks/paths/%s gains %q, which the base branch's copy does not hold; "+
|
||||
"say why after `# allow:` on its line, or keep it out", name, entry))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Judge narrows the findings by the lists: what is left to refuse, and the known findings that no
|
||||
// longer occur, which must leave the list.
|
||||
func Judge(found []Finding, shared, known map[string]bool) (refused []Finding, gone []string) {
|
||||
seen := map[string]bool{}
|
||||
for _, f := range found {
|
||||
if shared[f.Path] {
|
||||
continue
|
||||
}
|
||||
seen[f.Key()] = true
|
||||
if !known[f.Key()] {
|
||||
refused = append(refused, f)
|
||||
}
|
||||
}
|
||||
for k := range known {
|
||||
if !seen[k] {
|
||||
gone = append(gone, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(gone)
|
||||
return refused, gone
|
||||
}
|
||||
|
||||
func main() {
|
||||
root, baseDir := ".", ""
|
||||
args := os.Args[1:]
|
||||
if len(args) >= 2 && args[0] == "-base" {
|
||||
baseDir, args = args[1], args[2:]
|
||||
}
|
||||
if len(args) > 0 {
|
||||
root = args[0]
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
here := filepath.Join(root, "checks", "paths")
|
||||
shared, sharedWhy, err1 := list(filepath.Join(here, "shared"))
|
||||
known, knownWhy, err2 := list(filepath.Join(here, "known"))
|
||||
if err1 != nil || err2 != nil {
|
||||
fmt.Fprintln(os.Stderr, "the lists beside the check cannot be read:", err1, err2)
|
||||
os.Exit(2)
|
||||
}
|
||||
var grown []string
|
||||
if baseDir == "" {
|
||||
fmt.Println("NOT COMPARED: no base branch's lists were given, so additions to them were not judged")
|
||||
} else {
|
||||
baseShared, _, err1 := list(filepath.Join(baseDir, "shared"))
|
||||
baseKnown, _, err2 := list(filepath.Join(baseDir, "known"))
|
||||
if err1 != nil || err2 != nil {
|
||||
fmt.Fprintln(os.Stderr, "the base branch's lists cannot be read:", err1, err2)
|
||||
os.Exit(2)
|
||||
}
|
||||
grown = append(Grown("shared", shared, sharedWhy, baseShared), Grown("known", known, knownWhy, baseKnown)...)
|
||||
}
|
||||
refused, gone := Judge(found, shared, known)
|
||||
for _, g := range grown {
|
||||
fmt.Println(g)
|
||||
}
|
||||
for _, f := range refused {
|
||||
fmt.Println(f)
|
||||
}
|
||||
for _, k := range gone {
|
||||
fmt.Printf("%q is in checks/paths/known and no longer occurs: take it out, so it cannot come back\n", k)
|
||||
}
|
||||
if len(refused) > 0 || len(gone) > 0 || len(grown) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("paths: no module names another's state on its machine (%d known, listed to go)\n", len(known))
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEtcAndUsrLocalLibAreAModulesTooButNotInsideAContainer(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{
|
||||
"modules/power/module.json": `{"module":"power","resources":[{"id":"m","type":"directory","path":"/etc/mesh-power/moments"},` +
|
||||
`{"id":"s","type":"archive","path":"/usr/local/lib/mesh-power"}]}`,
|
||||
"modules/reader/module.json": `{"module":"reader"}`,
|
||||
"modules/reader/cmd/r/r.go": `const a, b = "/etc/mesh-power/moments/after-wake", "/usr/local/lib/mesh-power/bin/x"`,
|
||||
// A container that mounts something at that path inside itself names its own world.
|
||||
"modules/boxed/module.json": `{"module":"boxed","resources":[{"id":"c","type":"container",` +
|
||||
`"volumes":["/srv/boxed:/etc/mesh-power/moments",{"source":"/srv/b","target":"/usr/local/lib/mesh-power"}]}]}`,
|
||||
// A binary says what its source says.
|
||||
"modules/boxed/bin/tool": "\x00\x01/etc/mesh-power/moments\x00",
|
||||
})
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, f := range found {
|
||||
got = append(got, f.Module+" "+f.Path)
|
||||
}
|
||||
want := []string{"reader /etc/mesh-power/moments", "reader /usr/local/lib/mesh-power"}
|
||||
if strings.Join(got, "|") != strings.Join(want, "|") {
|
||||
t.Fatalf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedPathsPassAndKnownFindingsOnlyShrink(t *testing.T) {
|
||||
found := []Finding{
|
||||
{Module: "dns", File: "modules/dns/module.json", Path: "/etc/hosts", Owner: "hostname"},
|
||||
{Module: "laptop", File: "modules/laptop/k.go", Path: "/etc/triggers", Owner: "keys"},
|
||||
{Module: "new", File: "modules/new/n.go", Path: "/run/mesh-power", Owner: "power"},
|
||||
}
|
||||
shared := map[string]bool{"/etc/hosts": true}
|
||||
known := map[string]bool{
|
||||
"laptop /etc/triggers modules/laptop/k.go": true,
|
||||
"old /run/x modules/old/o.go": true,
|
||||
}
|
||||
refused, gone := Judge(found, shared, known)
|
||||
if len(refused) != 1 || refused[0].Module != "new" {
|
||||
t.Fatalf("refused %v", refused)
|
||||
}
|
||||
if len(gone) != 1 || gone[0] != "old /run/x modules/old/o.go" {
|
||||
t.Fatalf("gone %v", gone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADropInDirectoryIsNoModulesButItsFilesAre(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{
|
||||
"modules/memory/module.json": `{"module":"memory","resources":[{"id":"d","type":"directory","path":"/etc/sysctl.d"},` +
|
||||
`{"id":"f","type":"file","path":"/etc/sysctl.d/90-memory.conf"}]}`,
|
||||
"modules/other/module.json": `{"module":"other","resources":[{"id":"f","type":"file","path":"/etc/sysctl.d/80-other.conf"}]}`,
|
||||
"modules/other/cmd/o/o.go": `const a, b = "/etc/sysctl.d", "/etc/sysctl.d/90-memory.conf"`,
|
||||
})
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 || found[0].Path != "/etc/sysctl.d/90-memory.conf" || found[0].Module != "other" {
|
||||
t.Fatalf("%v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAListGrowsOnlyWithAReasonTheDiffShows(t *testing.T) {
|
||||
base := map[string]bool{"/etc/hosts": true}
|
||||
now := map[string]bool{"/etc/hosts": true, "/etc/a": true, "/etc/b": true}
|
||||
why := map[string]string{"/etc/a": "because", "/etc/b": "allow: every program reads it"}
|
||||
got := Grown("shared", now, why, base)
|
||||
if len(got) != 1 || !strings.Contains(got[0], `"/etc/a"`) {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Paths every program on a machine reads as the machine's own, not as a message from the module that
|
||||
# writes them (checks/paths, novox/hq ADR 0260). One per line, with why.
|
||||
/etc/hosts # every resolver on the machine reads it; the hostname module writes it for all of them
|
||||
@@ -19,9 +19,23 @@
|
||||
# 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, /var/lib, /usr/local/lib or /etc
|
||||
# that another module's manifest places — modules talk over the bus, not through each other's files.
|
||||
# Over every module; checks/paths/shared and checks/paths/known narrow it, and known only shrinks.
|
||||
set -eu
|
||||
|
||||
(cd checks/words && go test -count=1 . && go run . ../..)
|
||||
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
|
||||
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
|
||||
base_lists=""
|
||||
base_ref="origin/${MESH_CHECK_BASE:-main}"
|
||||
if git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null 2>&1 &&
|
||||
git cat-file -e "$base_ref:checks/paths/known" 2>/dev/null && git cat-file -e "$base_ref:checks/paths/shared" 2>/dev/null; then
|
||||
base_lists=$(mktemp -d)
|
||||
git show "$base_ref:checks/paths/known" >"$base_lists/known"
|
||||
git show "$base_ref:checks/paths/shared" >"$base_lists/shared"
|
||||
fi
|
||||
(cd checks/paths && go test -count=1 . && if [ -n "$base_lists" ]; then go run . -base "$base_lists" ../..; else go run . ../..; fi)
|
||||
|
||||
if [ -n "${MESH_GATE:-}" ]; then
|
||||
checked=$("$MESH_GATE" module check modules/*/module.json) || { printf '%s\n' "$checked"; exit 1; }
|
||||
|
||||
@@ -58,9 +58,11 @@ daemons and probably the same keys. Their GPU options are unverified.
|
||||
handling. Until then it stays as found, and it still calls the predecessor's `as-user`.
|
||||
- **The screenshot script** (`~/.config/i3/scripts/screenshot.sh`) is any machine's. The `i3` module
|
||||
binds it too. This module only binds the key the firmware sends for it.
|
||||
- **The bar's battery block.** It belongs here (a block that follows this model's hardware), but the
|
||||
bar has no way in yet (`i3status-rust` README). Once ADR 0210's contributions reach the bar seat,
|
||||
this module contributes it.
|
||||
- **The bar's battery block, and the power draw's.** A battery is any laptop's, not this model's, so
|
||||
the `power` module contributes both blocks wherever the machine reports a battery or a power meter
|
||||
(novox/hq ADR 0255). This module would contribute only what is this model's alone, such as the charge
|
||||
limit asusd holds; nothing yet. The predecessor's AMD GPU block is no model's either, and waits for a
|
||||
generic module (the `i3status-rust` README).
|
||||
|
||||
**Requires `x11-display`.** The i3 lines and the session scripts need a display, so the module is
|
||||
assigned where the display server is.
|
||||
@@ -306,7 +308,7 @@ The predecessor carried this model in its desktop module's `g14` and `laptop` fl
|
||||
| `g14-90-backlight.rules` | **this module**, the same path |
|
||||
| `g14-logind-brightness.conf` | **retired**: an unknown logind key that did nothing |
|
||||
| `laptop-monitor-wizard.sh`, `laptop-91-monitor-hotplug.rules` | **any laptop's, not this module's**: kept as found, for the `xorg` module's autorandr to replace |
|
||||
| `bottom-bar.g14.toml` (the battery block) | **waits**: this module's, once the bar takes contributions (ADR 0210) |
|
||||
| `bottom-bar.g14.toml` (the battery block) | **the `power` module's**: any machine with a battery gets it (ADR 0255) |
|
||||
| `razer-basilisk-battery-percentage` | **not this model's**: a mouse. No module carries it (`i3status-rust` README) |
|
||||
| `screenshot.sh` | **not this model's**: any machine's script, which the `i3` module binds too; this module binds Fn+F6 to it |
|
||||
| package `triggerhappy` | **depended on, kept as found** (outside the distribution, above) |
|
||||
|
||||
@@ -113,6 +113,12 @@ func TestTheVendorKeysRunTheModulesOwnScriptsAndTheyParse(t *testing.T) {
|
||||
if len(scripts) == 0 {
|
||||
t.Fatal("no scripts")
|
||||
}
|
||||
// The scripts are bash's. The build seat's Go toolchain carries no bash, so there they are said as
|
||||
// not parsed rather than failed: a missing interpreter is not a broken script.
|
||||
if _, err := exec.LookPath("bash"); err != nil {
|
||||
t.Logf("NOT PARSED: no bash on this machine, so the scripts' syntax was not checked")
|
||||
return
|
||||
}
|
||||
for _, s := range scripts {
|
||||
if out, err := exec.Command("bash", "-n", s).CombinedOutput(); err != nil {
|
||||
t.Errorf("%s: %v %s", s, err, out)
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
The bars as a module (novox/hq ADR 0208, research 026/05).
|
||||
|
||||
- Installs `i3status-rust`. The update block's `checkupdates` comes from `pacman-contrib`, which the `pacman` module installs on every node; two modules declaring one package is refused at composition.
|
||||
Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2). Requires `x11-display` on its own
|
||||
Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2) and renders the blocks other modules
|
||||
contribute to it (ADR 0255, below). Requires `x11-display` on its own
|
||||
machine: its bars are i3bar's.
|
||||
- Owns `~/.config/i3status-rust/`, both bars (`top-bar.toml`, `bottom-bar.toml`) and their icon set
|
||||
(`icons/custom-icons.toml`).
|
||||
@@ -25,25 +26,42 @@ The bars as a module (novox/hq ADR 0208, research 026/05).
|
||||
| `i3status_rust_block_run` | one block alone, with the bar's theme and icons, to see what it shows or why it errors |
|
||||
| `i3status_rust_themes` | the themes on the machine and the one each bar uses |
|
||||
|
||||
## The battery, and what else was left out
|
||||
## Blocks other modules contribute (novox/hq ADR 0255)
|
||||
|
||||
A bar block that follows one machine's hardware is that machine model's hardware module's (ADR 0208
|
||||
§1), so the bottom bar here has none:
|
||||
A block that depends on the machine's hardware is not written here. The module that knows the hardware
|
||||
contributes it to `node-bar` as data that says what it shows, and this module renders it:
|
||||
|
||||
- **the battery,** which only the laptop has;
|
||||
- **the GPU,** which was the laptop's AMD block, commented out for NVIDIA on the desktop;
|
||||
- **three Bluetooth headsets** by hardware address: one person's devices, not the bar's.
|
||||
- **What a contribution says:** which bar (`bottom`, `top`), which place (`resources`, beside the
|
||||
processor, memory and disks; `status`, beside the sound), an order from 0 to 99, what it shows
|
||||
(`battery`, `command`) and that kind's options. Nothing in it is i3status-rust's.
|
||||
- **How it is rendered:** the claim's `renders.block` is a template (its source is `files/block.tmpl`)
|
||||
that turns one piece into a `[[block]]`. The controller runs it and places the result where each bar
|
||||
says `${contribution:node-bar:block:bar=…,place=…}`. Each bar places both of its places once; the
|
||||
catalogue check refuses a holder that misses one.
|
||||
- **Where it applies:** a contribution may name a capability the machine must report. The `power`
|
||||
module's battery block names `battery` and its power-draw block names `power-meter`, so the laptop's
|
||||
bar gets both and a desktop only the power draw.
|
||||
- **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/`), dated with the value's own `at`, and the block
|
||||
runs `~/.local/bin/i3status-value`, which prints it, or nothing when it was measured more than 30 s
|
||||
ago, so the block hides a value frozen at its source. A watch that says nothing for a minute is
|
||||
looked at once: a key that is gone has expired at its source and shows nothing; a key still there
|
||||
means the watch died, so it is watched again and that is logged once. A refused watch is logged and
|
||||
tried again, and a value no bar shows any more is no longer kept. The bus grants this module the one key each block shows (the machine's own unless
|
||||
the block names another, `[a-z0-9-]`). 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.
|
||||
|
||||
i3status-rust reads no drop-in directory, and ADR 0208's slots cover `xinitrc` and `xresources` only,
|
||||
so a hardware module has no way into this file yet. Two ways forward, for the operator to choose:
|
||||
Still left out:
|
||||
|
||||
1. **Give the bar a slot.** A `shell`-style contribution `for: i3status-rust` would let the laptop's
|
||||
hardware module add its battery block. That is a decision record of its own, extending ADR 0208 §4.
|
||||
2. **Blocks that show only where they apply.** i3status-rust's common `if_command` option (for
|
||||
example `test -d /sys/class/power_supply/BAT0`) hides a block on a machine without the hardware.
|
||||
That is "say it by what is there" (ADR 0112), but the knowledge stays in the bar.
|
||||
- **The GPU's load,** the laptop's AMD block (commented out for NVIDIA on the desktop). A generic GPU
|
||||
block belongs to a generic module, which does not exist yet; a GPU's power is in the power draw.
|
||||
- **Three Bluetooth headsets** by hardware address: one person's devices, not the bar's.
|
||||
|
||||
Until one of them is chosen, **the laptop's bar shows no battery** once this module is assigned.
|
||||
The second way the README once offered, blocks with i3status-rust's `if_command` that hide themselves on
|
||||
a machine without the hardware, was rejected: the knowledge would stay in the bar, decided at run time.
|
||||
|
||||
## What it improves on what was found
|
||||
|
||||
@@ -53,6 +71,9 @@ Until one of them is chosen, **the laptop's bar shows no battery** once this mod
|
||||
- **The update count is the module's own,** adopted from the operator's `~/scripts/pkg-updates`. It
|
||||
counts AUR updates only where an AUR helper is installed. Clicking it lists the updates in the
|
||||
launcher's menu, instead of a theme from a cloned theme repository.
|
||||
- **The memory is what programs hold** (`$mem_used`), not `$mem_total_used`, which counts the cache the
|
||||
kernel gives back when asked and read 96 % on a machine at 73 %. It is the figure the
|
||||
`memory-pressure` module judges by (changed 2026-10-08).
|
||||
- **The faces** are JetBrains Mono Nerd Font, not Hack.
|
||||
- **The watchdog is no longer a hand-enabled user unit** that ran on one workstation and not the
|
||||
other. It runs on both, once per session, and ends with the session.
|
||||
@@ -79,7 +100,6 @@ Until one of them is chosen, **the laptop's bar shows no battery** once this mod
|
||||
|
||||
- `node-bar`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them,
|
||||
`mctl` reads them as unknown.
|
||||
- The battery block waits for one of the two ways above.
|
||||
- The watchdog would be a user unit once user-scoped units ship (mesh-host #72). It is not, because it
|
||||
runs per session and ends with the session, as a session-start line already does.
|
||||
- `pacman-contrib` is the `pacman` module's, which took it over as foreseen: a package is declared
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// i3status-rust renders the blocks other modules contribute (novox/hq ADR 0255): a bar-neutral piece
|
||||
// — what it shows, on which bar, where — in its own grammar, through the template its claim gives. The
|
||||
// controller runs the template; these tests run it the same way, with the one function it is given.
|
||||
|
||||
func blockTemplate(t *testing.T) *template.Template {
|
||||
t.Helper()
|
||||
m := readManifest(t)
|
||||
src, err := os.ReadFile("../../files/block.tmpl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Claims[0].Renders["block"] != string(src) {
|
||||
t.Fatal("the claim's template is not files/block.tmpl")
|
||||
}
|
||||
quote := func(v any) (string, error) {
|
||||
b, err := json.Marshal(fmt.Sprint(v))
|
||||
return string(b), err
|
||||
}
|
||||
return template.Must(template.New("block").Funcs(template.FuncMap{"quote": quote}).Parse(string(src)))
|
||||
}
|
||||
|
||||
func render(t *testing.T, tmpl *template.Template, piece string) string {
|
||||
t.Helper()
|
||||
view := map[string]any{"module": "power", "machine": "laptop"}
|
||||
if err := json.Unmarshal([]byte(piece), &view); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := tmpl.Execute(&b, view); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(b.String(), "<no value>") {
|
||||
t.Fatalf("%s rendered a missing value: %q", piece, b.String())
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestAContributedBatteryIsI3statusRustsBatteryBlock(t *testing.T) {
|
||||
tmpl := blockTemplate(t)
|
||||
got := render(t, tmpl, `{"bar":"bottom","place":"status","shows":"battery"}`)
|
||||
if !strings.HasPrefix(got, "[[block]]\nblock = \"battery\"\nformat = ") || strings.Contains(got, "device") ||
|
||||
!strings.Contains(got, `missing_format = ""`) || !strings.HasSuffix(got, "\n\n") {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
got = render(t, tmpl, `{"bar":"bottom","place":"status","shows":"battery","options":{"device":"BAT1"}}`)
|
||||
if !strings.Contains(got, "block = \"battery\"\ndevice = \"BAT1\"\nformat = ") {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContributedCommandIsACustomBlockQuoted(t *testing.T) {
|
||||
got := render(t, blockTemplate(t),
|
||||
`{"bar":"bottom","place":"status","shows":"command","options":{"command":"say \"hi\"","interval":5}}`)
|
||||
want := "[[block]]\nblock = \"custom\"\ncommand = \"say \\\"hi\\\"\"\ninterval = 5\n\n"
|
||||
if got != want {
|
||||
t.Fatalf("%q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachBarPlacesBothPlacesOnce(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
for _, bar := range []string{"bottom", "top"} {
|
||||
content := m.resource(t, bar+"-bar")["content"].(string)
|
||||
for _, place := range []string{"resources", "status"} {
|
||||
ph := "${contribution:node-bar:block:bar=" + bar + ",place=" + place + "}"
|
||||
if strings.Count(content, ph) != 1 {
|
||||
t.Errorf("the %s bar places %s %d times", bar, ph, strings.Count(content, ph))
|
||||
}
|
||||
}
|
||||
}
|
||||
bottom := m.resource(t, "bottom-bar")["content"].(string)
|
||||
resources := strings.Index(bottom, "place=resources")
|
||||
if !(strings.Index(bottom, `block = "memory"`) < resources && resources < strings.Index(bottom, `block = "docker"`)) ||
|
||||
!(strings.Index(bottom, `block = "sound"`) < strings.Index(bottom, "place=status") &&
|
||||
strings.Index(bottom, "place=status") < strings.Index(bottom, `block = "weather"`)) {
|
||||
t.Fatal("the places are not beside the memory and beside the sound")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMemoryIsWhatProgramsHoldNotTheCache(t *testing.T) {
|
||||
bottom := readManifest(t).resource(t, "bottom-bar")["content"].(string)
|
||||
if !strings.Contains(bottom, `format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"`) ||
|
||||
strings.Contains(bottom, "mem_total_used") {
|
||||
t.Fatal("the memory block counts the cache as used")
|
||||
}
|
||||
}
|
||||
|
||||
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,8 @@ type claim struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
// How the holder renders each kind its seat receives as data (novox/hq ADR 0255).
|
||||
Renders map[string]string `json:"renders"`
|
||||
}
|
||||
|
||||
type environment struct {
|
||||
|
||||
@@ -16,7 +16,8 @@ func TestItClaimsTheBarSeatAndRequiresTheXDisplay(t *testing.T) {
|
||||
if m.Module != "i3status-rust" || m.Seats != nil {
|
||||
t.Fatalf("module %q declares seats %v", m.Module, m.Seats)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-bar", Scope: "node"}}) {
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != "node-bar" || m.Claims[0].Scope != "node" ||
|
||||
len(m.Claims[0].Renders) != 1 || m.Claims[0].Renders["block"] == "" {
|
||||
t.Fatalf("claims: %+v", m.Claims)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
|
||||
@@ -32,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,294 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"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 the one key each such block shows. So it follows 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.
|
||||
//
|
||||
// **The value's age is its own.** The file is dated with the value's `at` when it has one, so the
|
||||
// block hides a value that stopped changing at its source, not only one that stopped arriving here.
|
||||
|
||||
// FollowEvery is how often the bars are read again and every followed value looked at; Silent is how
|
||||
// long a watch may say nothing before the value is read again — twice the power draw's lifetime.
|
||||
var (
|
||||
FollowEvery = 30 * time.Second
|
||||
Silent = 60 * time.Second
|
||||
)
|
||||
|
||||
// valueCall is how a bar names a value: the command the template renders. A key is a machine's name.
|
||||
var valueCall = regexp.MustCompile(`i3status-value ([a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*) ([a-z0-9][a-z0-9-]*)`)
|
||||
|
||||
// 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 value to its file: its `text`, dated with its `at` when it has one; no file once the
|
||||
// value is deleted. Each write goes through a temporary file of its own and is renamed into place, so a
|
||||
// watch's write and a reread's never meet halfway.
|
||||
func keep(dir string, w Wanted, op string, value json.RawMessage) error {
|
||||
path := filepath.Join(dir, w.file())
|
||||
if op == "delete" || op == "purge" || value == nil {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var v struct {
|
||||
Text string `json:"text"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
if err := json.Unmarshal(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, err := os.CreateTemp(dir, "."+w.file()+".*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // gone after the rename; left behind only on a failure
|
||||
if _, err := tmp.WriteString(v.Text + "\n"); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if at, err := time.Parse(time.RFC3339, v.At); err == nil {
|
||||
if err := os.Chtimes(tmp.Name(), at, at); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
// Bus is what following needs of the bus: a watch of one key, and a read of it. The SDK's in the
|
||||
// bundle, a fake in a test.
|
||||
type Bus struct {
|
||||
Watch func(state, key string, handle func(op string, value json.RawMessage) error) error
|
||||
Get func(state, key string) (json.RawMessage, bool, error)
|
||||
}
|
||||
|
||||
// following is one value followed.
|
||||
type following struct {
|
||||
// last is when its watch last handed anything over, or when it was last started or found expired.
|
||||
last time.Time
|
||||
// gen is which watch is the current one: a watch started again leaves the old one running (the SDK
|
||||
// cannot end it), and what an old one hands over is dropped.
|
||||
gen int
|
||||
// stopped is a value no bar shows any more.
|
||||
stopped bool
|
||||
// dead is a watch found silent while its value went on, said once until a watch speaks again.
|
||||
dead bool
|
||||
// expired is a value its source stopped giving: the key is gone and its watch has nothing to say.
|
||||
expired bool
|
||||
}
|
||||
|
||||
// Follower follows the values the bars name.
|
||||
type Follower struct {
|
||||
Dir string
|
||||
Bus Bus
|
||||
Now func() time.Time
|
||||
Say func(string)
|
||||
|
||||
mu sync.Mutex
|
||||
of map[Wanted]*following
|
||||
}
|
||||
|
||||
// Round reads the bars once and brings what is followed into line with them:
|
||||
// - a value a bar names and nothing follows is watched; a watch the runtime refuses — a read not
|
||||
// granted yet — is said and tried again next round;
|
||||
// - a value whose watch has said nothing for Silent is read once. A key that is gone has expired at
|
||||
// its source (its lifetime ends without an event), and is left until the watch speaks or another
|
||||
// Silent has passed. A key still there means the watch missed it: the value is kept, the watch is
|
||||
// started again, and that is said once — not every round — until a watch speaks again;
|
||||
// - a value no bar names any more is no longer kept: its file goes, and what its watch hands over is
|
||||
// dropped. The SDK has no way to end a watch; one that ends with the bundle is the price.
|
||||
func (f *Follower) Round(files []string) {
|
||||
f.mu.Lock()
|
||||
if f.of == nil {
|
||||
f.of = map[Wanted]*following{}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
wanted := map[Wanted]bool{}
|
||||
for _, w := range wantedIn(files) {
|
||||
wanted[w] = true
|
||||
f.mu.Lock()
|
||||
fw, known := f.of[w]
|
||||
var stopped, silent bool
|
||||
if known {
|
||||
stopped = fw.stopped
|
||||
silent = f.Now().Sub(fw.last) > Silent
|
||||
}
|
||||
f.mu.Unlock()
|
||||
switch {
|
||||
case !known:
|
||||
f.start(w)
|
||||
case stopped:
|
||||
f.mu.Lock()
|
||||
fw.stopped = false
|
||||
f.mu.Unlock()
|
||||
f.start(w)
|
||||
case silent:
|
||||
f.silent(w)
|
||||
}
|
||||
}
|
||||
f.mu.Lock()
|
||||
var gone []Wanted
|
||||
for w, fw := range f.of {
|
||||
if !wanted[w] && !fw.stopped {
|
||||
fw.stopped = true
|
||||
fw.gen++
|
||||
gone = append(gone, w)
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
for _, w := range gone {
|
||||
if err := keep(f.Dir, w, "delete", nil); err != nil {
|
||||
f.Say(err.Error())
|
||||
}
|
||||
f.Say(fmt.Sprintf("no bar shows %s %s any more; it is no longer kept", w.State, w.Key))
|
||||
}
|
||||
}
|
||||
|
||||
// start watches a value, as a new generation. Its entry is there before the watch begins, because the
|
||||
// SDK hands every current value over before Watch returns, and a value handed to nobody is lost.
|
||||
func (f *Follower) start(w Wanted) {
|
||||
f.mu.Lock()
|
||||
fw, existed := f.of[w]
|
||||
if !existed {
|
||||
fw = &following{}
|
||||
f.of[w] = fw
|
||||
}
|
||||
fw.gen++
|
||||
gen := fw.gen
|
||||
fw.last = f.Now()
|
||||
f.mu.Unlock()
|
||||
err := f.Bus.Watch(w.State, w.Key, func(op string, value json.RawMessage) error {
|
||||
f.mu.Lock()
|
||||
current := f.of[w] == fw && fw.gen == gen && !fw.stopped
|
||||
if current {
|
||||
fw.last, fw.dead, fw.expired = f.Now(), false, false
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if !current {
|
||||
return nil
|
||||
}
|
||||
return keep(f.Dir, w, op, value)
|
||||
})
|
||||
if err != nil {
|
||||
f.mu.Lock()
|
||||
if !existed {
|
||||
delete(f.of, w)
|
||||
} else {
|
||||
fw.gen++ // the refused watch is no generation; the next round tries again
|
||||
}
|
||||
f.mu.Unlock()
|
||||
f.Say(fmt.Sprintf("cannot watch %s %s: %v; trying again in %s", w.State, w.Key, err, FollowEvery))
|
||||
}
|
||||
}
|
||||
|
||||
// silent looks once at a value whose watch has said nothing for Silent.
|
||||
func (f *Follower) silent(w Wanted) {
|
||||
value, found, err := f.Bus.Get(w.State, w.Key)
|
||||
f.mu.Lock()
|
||||
fw := f.of[w]
|
||||
fw.last = f.Now()
|
||||
wasDead, wasExpired := fw.dead, fw.expired
|
||||
f.mu.Unlock()
|
||||
switch {
|
||||
case err != nil:
|
||||
f.Say(fmt.Sprintf("cannot read %s %s: %v", w.State, w.Key, err))
|
||||
case !found:
|
||||
// Expired at its source: nothing to show, and nothing wrong with the watch.
|
||||
if err := keep(f.Dir, w, "delete", nil); err != nil {
|
||||
f.Say(err.Error())
|
||||
}
|
||||
f.mu.Lock()
|
||||
fw.expired = true
|
||||
f.mu.Unlock()
|
||||
if !wasExpired {
|
||||
f.Say(fmt.Sprintf("%s %s has expired at its source; showing nothing until it is given again", w.State, w.Key))
|
||||
}
|
||||
default:
|
||||
if err := keep(f.Dir, w, "put", value); err != nil {
|
||||
f.Say(err.Error())
|
||||
}
|
||||
if !wasDead {
|
||||
f.Say(fmt.Sprintf("the watch of %s %s said nothing for %s while its value went on; watching it again",
|
||||
w.State, w.Key, Silent))
|
||||
}
|
||||
f.mu.Lock()
|
||||
fw.dead = true
|
||||
f.mu.Unlock()
|
||||
f.start(w)
|
||||
}
|
||||
}
|
||||
|
||||
// FollowValues runs a Round every FollowEvery for as long as the bundle runs, so a block a push adds
|
||||
// is followed without a restart.
|
||||
func FollowValues() {
|
||||
f := &Follower{Dir: valuesDir(), Now: time.Now,
|
||||
Say: func(s string) { fmt.Fprintln(os.Stderr, "i3status-rust:", s) },
|
||||
Bus: Bus{
|
||||
Watch: func(state, key string, handle func(string, json.RawMessage) error) error {
|
||||
return stdio.State(state).Watch(key, func(c stdio.StateChange) error { return handle(c.Op, c.Value) })
|
||||
},
|
||||
Get: func(state, key string) (json.RawMessage, bool, error) {
|
||||
e, err := stdio.State(state).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
},
|
||||
}}
|
||||
for {
|
||||
files, _ := filepath.Glob(filepath.Join(configDir(), "*.toml"))
|
||||
f.Round(files)
|
||||
time.Sleep(FollowEvery)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0260: a value another module keeps reaches the bar over the bus, through this
|
||||
// module's own bundle and directory; a dead watch is never silent; a removed block is no longer kept;
|
||||
// and a value frozen at its source hides.
|
||||
|
||||
func TestAValueIsKeptAsItsTextDatedByItsAtAndGoneWhenDeleted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := Wanted{"power.draw", "laptop"}
|
||||
at := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
|
||||
if err := keep(dir, w, "put", json.RawMessage(`{"text":"18.2 W machine","at":"2026-10-08T12:00:00Z"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "power.draw@laptop")
|
||||
b, err := os.ReadFile(path)
|
||||
info, _ := os.Stat(path)
|
||||
if err != nil || string(b) != "18.2 W machine\n" || !info.ModTime().Equal(at) {
|
||||
t.Fatalf("%q %v, dated %v", b, err, info.ModTime())
|
||||
}
|
||||
if err := keep(dir, w, "delete", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("a deleted value is still shown")
|
||||
}
|
||||
if err := keep(dir, w, "put", json.RawMessage(`"no text"`)); err == nil {
|
||||
t.Fatal("a value with no text was kept")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeBus struct {
|
||||
refuse bool
|
||||
watched []Wanted
|
||||
handles []func(string, json.RawMessage) error
|
||||
current json.RawMessage // handed to a new watch before Watch returns, as the SDK does
|
||||
gets int
|
||||
value json.RawMessage
|
||||
keyFound bool
|
||||
}
|
||||
|
||||
func (b *fakeBus) bus() Bus {
|
||||
return Bus{
|
||||
Watch: func(state, key string, h func(string, json.RawMessage) error) error {
|
||||
if b.refuse {
|
||||
return errors.New("not granted")
|
||||
}
|
||||
b.watched = append(b.watched, Wanted{state, key})
|
||||
b.handles = append(b.handles, h)
|
||||
if b.current != nil {
|
||||
return h("put", b.current)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Get: func(string, string) (json.RawMessage, bool, error) { b.gets++; return b.value, b.keyFound, nil },
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBus) latest() func(string, json.RawMessage) error { return b.handles[len(b.handles)-1] }
|
||||
|
||||
func barFiles(t *testing.T, content string) []string {
|
||||
t.Helper()
|
||||
f := filepath.Join(t.TempDir(), "bottom-bar.toml")
|
||||
if err := os.WriteFile(f, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return []string{f}
|
||||
}
|
||||
|
||||
type follow struct {
|
||||
t *testing.T
|
||||
now time.Time
|
||||
said []string
|
||||
bus *fakeBus
|
||||
dir string
|
||||
f *Follower
|
||||
bar []string
|
||||
}
|
||||
|
||||
func newFollow(t *testing.T) *follow {
|
||||
x := &follow{t: t, now: time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC), bus: &fakeBus{}, dir: t.TempDir()}
|
||||
x.f = &Follower{Dir: x.dir, Bus: x.bus.bus(), Now: func() time.Time { return x.now }, Say: func(s string) { x.said = append(x.said, s) }}
|
||||
x.bar = barFiles(t, `command = "~/.local/bin/i3status-value power.draw laptop"`)
|
||||
return x
|
||||
}
|
||||
|
||||
func (x *follow) shown() string {
|
||||
b, err := os.ReadFile(filepath.Join(x.dir, "power.draw@laptop"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestTheValueAWatchHandsOverAtOnceIsKept(t *testing.T) {
|
||||
x := newFollow(t)
|
||||
x.bus.current = json.RawMessage(`{"text":"18.2 W machine"}`)
|
||||
x.f.Round(x.bar)
|
||||
if x.shown() != "18.2 W machine\n" {
|
||||
t.Fatalf("the value handed over before Watch returned was dropped: %q", x.shown())
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusedWatchIsSaidAndTriedAgain(t *testing.T) {
|
||||
x := newFollow(t)
|
||||
x.bus.refuse = true
|
||||
x.f.Round(x.bar)
|
||||
if len(x.said) != 1 || !strings.Contains(x.said[0], "cannot watch power.draw laptop: not granted") {
|
||||
t.Fatalf("%v", x.said)
|
||||
}
|
||||
x.bus.refuse = false
|
||||
x.f.Round(x.bar)
|
||||
if len(x.bus.watched) != 1 {
|
||||
t.Fatalf("watched %v", x.bus.watched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASilentWatchWhoseValueWentOnIsWatchedAgainAndSaidOnce(t *testing.T) {
|
||||
x := newFollow(t)
|
||||
x.f.Round(x.bar)
|
||||
x.bus.value, x.bus.keyFound = json.RawMessage(`{"text":"9.0 W CPU"}`), true
|
||||
for i := 0; i < 3; i++ {
|
||||
x.now = x.now.Add(Silent + time.Second)
|
||||
x.f.Round(x.bar)
|
||||
}
|
||||
if len(x.bus.watched) != 4 || x.shown() != "9.0 W CPU\n" {
|
||||
t.Fatalf("watched %d times, shows %q", len(x.bus.watched), x.shown())
|
||||
}
|
||||
dead := 0
|
||||
for _, s := range x.said {
|
||||
if strings.Contains(s, "watching it again") {
|
||||
dead++
|
||||
}
|
||||
}
|
||||
if dead != 1 {
|
||||
t.Fatalf("a dead watch said %d times: %v", dead, x.said)
|
||||
}
|
||||
// What an old watch hands over is dropped; the current one's is kept.
|
||||
if err := x.bus.handles[0]("put", json.RawMessage(`{"text":"old"}`)); err != nil || x.shown() != "9.0 W CPU\n" {
|
||||
t.Fatalf("an old watch wrote: %q %v", x.shown(), err)
|
||||
}
|
||||
if err := x.bus.latest()("put", json.RawMessage(`{"text":"10.0 W CPU"}`)); err != nil || x.shown() != "10.0 W CPU\n" {
|
||||
t.Fatalf("the current watch did not: %q %v", x.shown(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExpiredKeyIsExpiredNotADeadWatch(t *testing.T) {
|
||||
x := newFollow(t)
|
||||
x.bus.current = json.RawMessage(`{"text":"9.0 W CPU"}`)
|
||||
x.f.Round(x.bar)
|
||||
x.bus.current, x.bus.keyFound = nil, false
|
||||
for i := 0; i < 3; i++ {
|
||||
x.now = x.now.Add(Silent + time.Second)
|
||||
x.f.Round(x.bar)
|
||||
}
|
||||
if len(x.bus.watched) != 1 || x.shown() != "" {
|
||||
t.Fatalf("watched %d times, shows %q", len(x.bus.watched), x.shown())
|
||||
}
|
||||
for _, s := range x.said {
|
||||
if strings.Contains(s, "watching it again") {
|
||||
t.Fatalf("an expired key was taken for a dead watch: %v", x.said)
|
||||
}
|
||||
}
|
||||
if n := strings.Count(strings.Join(x.said, "|"), "expired"); n != 1 {
|
||||
t.Fatalf("expiry said %d times: %v", n, x.said)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARemovedBlockIsNoLongerKept(t *testing.T) {
|
||||
x := newFollow(t)
|
||||
x.bus.current = json.RawMessage(`{"text":"9.0 W CPU"}`)
|
||||
x.f.Round(x.bar)
|
||||
x.f.Round(barFiles(t, "no values here"))
|
||||
if x.shown() != "" || !strings.Contains(x.said[len(x.said)-1], "no longer kept") {
|
||||
t.Fatalf("%q %v", x.shown(), x.said)
|
||||
}
|
||||
if err := x.bus.latest()("put", json.RawMessage(`{"text":"after"}`)); err != nil || x.shown() != "" {
|
||||
t.Fatal("a stopped watch still writes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWatchAndAReadWritingAtOnceLeaveOneWholeValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := Wanted{"power.draw", "laptop"}
|
||||
done := make(chan error, 20)
|
||||
for i := 0; i < 20; i++ {
|
||||
go func(i int) {
|
||||
done <- keep(dir, w, "put", json.RawMessage(`{"text":"value `+strings.Repeat("x", i)+`"}`))
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(dir, "power.draw@laptop"))
|
||||
if !strings.HasPrefix(string(b), "value ") || strings.Count(string(b), "\n") != 1 {
|
||||
t.Fatalf("%q", b)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("temporary files left: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeyIsAMachinesName(t *testing.T) {
|
||||
if w := wantedIn(barFiles(t, `i3status-value power.draw ../etc`+"\n"+`i3status-value power.draw Laptop`)); len(w) != 0 {
|
||||
t.Fatalf("%v", w)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
fresh := time.Now().UTC().Format(time.RFC3339)
|
||||
if err := keep(dir, Wanted{"power.draw", "laptop"}, "put", json.RawMessage(`{"text":"8.0 W CPU","at":"`+fresh+`"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := run(); got != "8.0 W CPU" {
|
||||
t.Fatalf("fresh: %q", got)
|
||||
}
|
||||
// A value measured 31 s ago hides, however recently it arrived.
|
||||
old := time.Now().Add(-31 * time.Second).UTC().Format(time.RFC3339)
|
||||
if err := keep(dir, Wanted{"power.draw", "laptop"}, "put", json.RawMessage(`{"text":"8.0 W CPU","at":"`+old+`"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := run(); got != "" {
|
||||
t.Fatalf("measured 31 s ago: %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
|
||||
@@ -0,0 +1,26 @@
|
||||
{{- /* 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), module and machine. */ -}}
|
||||
{{- if eq .shows "battery" -}}
|
||||
[[block]]
|
||||
block = "battery"
|
||||
{{with .options}}{{with .device}}device = {{quote .}}
|
||||
{{end}}{{end -}}
|
||||
format = "$icon $percentage{ $time|}"
|
||||
charging_format = "$icon $percentage{ $time|}"
|
||||
not_charging_format = "$icon $percentage"
|
||||
full_format = "$icon $percentage"
|
||||
empty_format = "$icon $percentage"
|
||||
missing_format = ""
|
||||
{{- else if eq .shows "command" -}}
|
||||
[[block]]
|
||||
block = "custom"
|
||||
command = {{quote .options.command}}
|
||||
interval = {{.options.interval}}
|
||||
{{- 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}}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
|
||||
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
|
||||
# person's devices left out: the battery and the GPU belong to a machine model's hardware module,
|
||||
# and a headset's address is not the bar's to know. Every block here works the same on any machine.
|
||||
# person's devices left out: a block that depends on the hardware is contributed by the module that
|
||||
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
|
||||
# here works the same on any machine.
|
||||
icons_format = "{icon}"
|
||||
|
||||
[theme]
|
||||
@@ -66,9 +67,14 @@ format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
|
||||
|
||||
[[block]]
|
||||
block = "memory"
|
||||
format = "$icon $mem_total_used.eng(w:2) ($mem_total_used_percents.eng(w:2))"
|
||||
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
|
||||
# module judges by. Counting the cache as used read near full on a machine with room to spare.
|
||||
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
|
||||
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
|
||||
|
||||
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
|
||||
# what they show by this module's template, and only on a machine that has what they show.
|
||||
${contribution:node-bar:block:bar=bottom,place=resources}
|
||||
[[block]]
|
||||
block = "docker"
|
||||
interval = 2
|
||||
@@ -80,6 +86,9 @@ block = "sound"
|
||||
button = "left"
|
||||
cmd = "pavucontrol"
|
||||
|
||||
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
|
||||
# power draw, where the machine has them.
|
||||
${contribution:node-bar:block:bar=bottom,place=status}
|
||||
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
|
||||
[[block]]
|
||||
block = "weather"
|
||||
|
||||
@@ -31,3 +31,7 @@ short = " $title.str(max_w:100) |"
|
||||
[[block]]
|
||||
block = "uptime"
|
||||
interval = 60
|
||||
|
||||
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
|
||||
# so that one that does is never dropped.
|
||||
${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-bar",
|
||||
"scope": "node"
|
||||
"scope": "node",
|
||||
"renders": {
|
||||
"block": "{{- /* A block other modules contribute, in i3status-rust's grammar (novox/hq ADR 0255). The controller\nrenders one piece at a time: its fields (bar, place, order, shows, options), 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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
@@ -52,7 +55,7 @@
|
||||
"path": "${machine:account-home}/.config/i3status-rust/top-bar.toml",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"content": "# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.\n# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"focused_window\"\n[block.format]\nfull = \" $title.str(max_w:50) |\"\nshort = \" $title.str(max_w:100) |\"\n\n[[block]]\nblock = \"uptime\"\ninterval = 60\n"
|
||||
"content": "# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.\n# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"focused_window\"\n[block.format]\nfull = \" $title.str(max_w:50) |\"\nshort = \" $title.str(max_w:100) |\"\n\n[[block]]\nblock = \"uptime\"\ninterval = 60\n\n# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept\n# so that one that does is never dropped.\n${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}\n"
|
||||
},
|
||||
{
|
||||
"id": "bottom-bar",
|
||||
@@ -60,7 +63,7 @@
|
||||
"path": "${machine:account-home}/.config/i3status-rust/bottom-bar.toml",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"content": "# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one\n# person's devices left out: the battery and the GPU belong to a machine model's hardware module,\n# and a headset's address is not the bar's to know. Every block here works the same on any machine.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"tea_timer\"\nformat = \"$icon{ $minutes:$seconds|}\"\ndone_cmd = \"notify-send 'Timer finished'\"\n\n# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good\n# count through a network gap and shows an error only after ten failed checks in a row. Click: the\n# list, in the launcher's menu.\n[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-updates 10\"\njson = true\ninterval = 1800\n[[block.click]]\nbutton = \"left\"\ncmd = \"checkupdates | dmenu -l 20 -p Updates\"\n\n[[block]]\nblock = \"cpu\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon / $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/home\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon /home $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"memory\"\nformat = \"$icon $mem_total_used.eng(w:2) ($mem_total_used_percents.eng(w:2))\"\nformat_alt = \"$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))\"\n\n[[block]]\nblock = \"docker\"\ninterval = 2\nformat = \"$icon $running/$total\"\n\n[[block]]\nblock = \"sound\"\n[[block.click]]\nbutton = \"left\"\ncmd = \"pavucontrol\"\n\n# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.\n[[block]]\nblock = \"weather\"\nformat = \"$icon $weather_verbose ($location) $temp\"\nautolocate = true\nautolocate_interval = 600\n[block.service]\nname = \"metno\"\n\n[[block]]\nblock = \"notify\"\ndriver = \"dunst\"\nformat = \" $icon {($notification_count.eng(w:1))|}\"\n\n[[block]]\nblock = \"time\"\ninterval = 1\nformat = \"$timestamp.datetime(f:'%a %d/%m %H:%M:%S')\"\n"
|
||||
"content": "# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one\n# person's devices left out: a block that depends on the hardware is contributed by the module that\n# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written\n# here works the same on any machine.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"tea_timer\"\nformat = \"$icon{ $minutes:$seconds|}\"\ndone_cmd = \"notify-send 'Timer finished'\"\n\n# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good\n# count through a network gap and shows an error only after ten failed checks in a row. Click: the\n# list, in the launcher's menu.\n[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-updates 10\"\njson = true\ninterval = 1800\n[[block.click]]\nbutton = \"left\"\ncmd = \"checkupdates | dmenu -l 20 -p Updates\"\n\n[[block]]\nblock = \"cpu\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon / $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/home\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon /home $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"memory\"\n# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure\n# module judges by. Counting the cache as used read near full on a machine with room to spare.\nformat = \"$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))\"\nformat_alt = \"$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))\"\n\n# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from\n# what they show by this module's template, and only on a machine that has what they show.\n${contribution:node-bar:block:bar=bottom,place=resources}\n[[block]]\nblock = \"docker\"\ninterval = 2\nformat = \"$icon $running/$total\"\n\n[[block]]\nblock = \"sound\"\n[[block.click]]\nbutton = \"left\"\ncmd = \"pavucontrol\"\n\n# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and\n# power draw, where the machine has them.\n${contribution:node-bar:block:bar=bottom,place=status}\n# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.\n[[block]]\nblock = \"weather\"\nformat = \"$icon $weather_verbose ($location) $temp\"\nautolocate = true\nautolocate_interval = 600\n[block.service]\nname = \"metno\"\n\n[[block]]\nblock = \"notify\"\ndriver = \"dunst\"\nformat = \" $icon {($notification_count.eng(w:1))|}\"\n\n[[block]]\nblock = \"time\"\ninterval = 1\nformat = \"$timestamp.datetime(f:'%a %d/%m %H:%M:%S')\"\n"
|
||||
},
|
||||
{
|
||||
"id": "icons",
|
||||
@@ -85,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": {
|
||||
|
||||
@@ -13,6 +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 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
|
||||
@@ -31,6 +32,52 @@ 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, 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
|
||||
bar renders them, and each names the capability the node-engine reports for it:
|
||||
|
||||
| block | where | what it shows |
|
||||
|---|---|---|
|
||||
| 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:
|
||||
|
||||
- **on battery**, the battery's discharge: `18.2 W machine`;
|
||||
- **otherwise**, the processor package's power from the kernel's powercap energy counter (RAPL, Intel
|
||||
and AMD), plus a discrete graphics device's own power where it reports one: `14.0 W CPU+GPU` or
|
||||
`14.0 W CPU`. That is part of the machine, and is called that part.
|
||||
- **The machine at the wall** needs a metered plug or a UPS and is not measured.
|
||||
|
||||
**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`). The
|
||||
state is `per-machine`: each machine's instance is granted its own machine's key and no other.
|
||||
|
||||
**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
|
||||
to condition a resource on another seat (the bar) being held on the machine. It costs one process
|
||||
reading a few kernel files every five seconds (novox/hq ADR 0255 §9).
|
||||
|
||||
## Settings
|
||||
|
||||
`handle-power-key`, `handle-lid-switch`, `handle-lid-switch-external-power` and
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The power the machine draws, measured honestly and said with what was measured (novox/hq ADR 0255).
|
||||
//
|
||||
// - **On battery,** the battery's discharge rate. That is the whole machine.
|
||||
// - **Otherwise** — on mains, or on a machine without a battery — the processor package's power from
|
||||
// the kernel's powercap energy counter (RAPL, on Intel and AMD alike: a rate from the counter's
|
||||
// change), plus a discrete graphics device's own power where it reports one. That is the processor
|
||||
// (and graphics) part, never called the machine's.
|
||||
// - **The machine at the wall** needs a metered plug or a UPS, and is not measured here.
|
||||
//
|
||||
// The energy counter is root's to read, on purpose: read often, it leaks what the processor is doing
|
||||
// (the PLATYPUS attack). So the counter is never opened to the operator's account. `power draw` runs as
|
||||
// root, as the module's process, samples every SampleEvery, and writes only the averaged rate 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 sampler leaves the reading for the module's bundle, and SampleEvery how often.
|
||||
const (
|
||||
DrawFile = "/run/mesh-power/draw"
|
||||
SampleEvery = 5 * time.Second
|
||||
)
|
||||
|
||||
// Draw is one reading: each part measured, in watts, and what it covers.
|
||||
type Draw struct {
|
||||
// Machine is the battery's discharge, when on battery: the whole machine.
|
||||
Machine *float64
|
||||
// CPU is the processor packages' power, from the energy counters.
|
||||
CPU *float64
|
||||
// GPU is the discrete graphics devices' own power, where they report it; asleep counts as none.
|
||||
GPU *float64
|
||||
}
|
||||
|
||||
// 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 {
|
||||
case d.Machine != nil:
|
||||
return fmt.Sprintf("%.1f W machine", *d.Machine)
|
||||
case d.CPU != nil && d.GPU != nil:
|
||||
return fmt.Sprintf("%.1f W CPU+GPU", *d.CPU+*d.GPU)
|
||||
case d.CPU != nil:
|
||||
return fmt.Sprintf("%.1f W CPU", *d.CPU)
|
||||
case d.GPU != nil:
|
||||
return fmt.Sprintf("%.1f W GPU", *d.GPU)
|
||||
}
|
||||
return "power not measured"
|
||||
}
|
||||
|
||||
// counter is one energy counter's reading, in microjoules, with the range it wraps at.
|
||||
type counter struct {
|
||||
uj, max int64
|
||||
}
|
||||
|
||||
// MaxPackageWatts is more than any processor package draws. A rate above it is a counter that was
|
||||
// reset — after a resume, say — not power, and that sample gives no reading.
|
||||
const MaxPackageWatts = 1000
|
||||
|
||||
// packageCounters is every processor package's energy counter, by the package's name, each counted
|
||||
// once. Intel exposes some packages twice, through MSRs (`intel-rapl:N`) and through MMIO
|
||||
// (`intel-rapl-mmio:N`), under one name; the MSR one is read, and the second never adds to it.
|
||||
func (m *Machine) packageCounters() map[string]counter {
|
||||
out := map[string]counter{}
|
||||
from := map[string]string{}
|
||||
for _, dir := range m.glob("/sys/class/powercap/*") {
|
||||
name := m.read(dir + "/name")
|
||||
if !strings.HasPrefix(name, "package") {
|
||||
continue
|
||||
}
|
||||
if was, seen := from[name]; seen && !strings.Contains(filepath.Base(was), "mmio") {
|
||||
continue
|
||||
}
|
||||
uj, ok := m.readInt(dir + "/energy_uj")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
max, _ := m.readInt(dir + "/max_energy_range_uj")
|
||||
out[name] = counter{uj: uj, max: max}
|
||||
from[name] = dir
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cpuWatts is the packages' power between two readings `elapsed` apart; nil when no counter was read
|
||||
// both times. A counter that wrapped is counted across its range; one that gives a rate no package
|
||||
// draws was reset, and the sample gives nothing rather than a number that is not power.
|
||||
func cpuWatts(before, after map[string]counter, elapsed time.Duration) *float64 {
|
||||
if elapsed <= 0 {
|
||||
return nil
|
||||
}
|
||||
var joules float64
|
||||
read := false
|
||||
for dir, a := range after {
|
||||
b, ok := before[dir]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delta := a.uj - b.uj
|
||||
if delta < 0 {
|
||||
if a.max <= 0 {
|
||||
continue
|
||||
}
|
||||
delta += a.max
|
||||
}
|
||||
if float64(delta)/1e6/elapsed.Seconds() > MaxPackageWatts {
|
||||
return nil
|
||||
}
|
||||
joules += float64(delta) / 1e6
|
||||
read = true
|
||||
}
|
||||
if !read {
|
||||
return nil
|
||||
}
|
||||
w := joules / elapsed.Seconds()
|
||||
return &w
|
||||
}
|
||||
|
||||
// batteryWatts is the system battery's discharge, when it is discharging; nil otherwise.
|
||||
func (m *Machine) batteryWatts() *float64 {
|
||||
var total float64
|
||||
found := false
|
||||
for _, s := range m.Supplies() {
|
||||
if s.Type != "Battery" || s.Device || s.Status != "Discharging" {
|
||||
continue
|
||||
}
|
||||
dir := "/sys/class/power_supply/" + s.Name
|
||||
if p, ok := m.readInt(dir + "/power_now"); ok {
|
||||
total += float64(abs(p)) / 1e6
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
i, iok := m.readInt(dir + "/current_now")
|
||||
v, vok := m.readInt(dir + "/voltage_now")
|
||||
if iok && vok {
|
||||
total += float64(abs(i)) * float64(v) / 1e12
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
return &total
|
||||
}
|
||||
|
||||
func abs(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// gpuWatts is the discrete graphics devices' own power, where they report it; nil when none does.
|
||||
//
|
||||
// - An AMD device's hwmon power counts only when it has its own memory (`mem_info_vram_vendor`): on
|
||||
// an APU the integrated graphics report the whole package, which the processor's counter already
|
||||
// holds, and counting it twice would be a lie.
|
||||
// - An NVIDIA device is asked through nvidia-smi only while it is awake: asking wakes a suspended GPU,
|
||||
// which on a hybrid laptop costs more than the answer is worth. Asleep, it draws next to nothing and
|
||||
// counts as none.
|
||||
func (m *Machine) gpuWatts(ctx context.Context) *float64 {
|
||||
var total float64
|
||||
found := false
|
||||
for _, card := range m.glob("/sys/class/drm/card[0-9]*") {
|
||||
if strings.Contains(filepath.Base(card), "-") || !m.exists(card+"/device/mem_info_vram_vendor") {
|
||||
continue
|
||||
}
|
||||
// Asleep, it is not read: reading its sensors wakes it, as asking nvidia-smi would.
|
||||
if status := m.read(card + "/device/power/runtime_status"); status != "" && status != "active" {
|
||||
continue
|
||||
}
|
||||
for _, hw := range m.glob(card + "/device/hwmon/hwmon*") {
|
||||
uw, ok := m.readInt(hw + "/power1_average")
|
||||
if !ok {
|
||||
uw, ok = m.readInt(hw + "/power1_input")
|
||||
}
|
||||
if ok {
|
||||
total += float64(uw) / 1e6
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, gpu := range m.glob("/proc/driver/nvidia/gpus/*") {
|
||||
address := filepath.Base(gpu)
|
||||
if m.read("/sys/bus/pci/devices/"+address+"/power/runtime_status") != "active" {
|
||||
continue
|
||||
}
|
||||
out, err := m.Run(ctx, "nvidia-smi", "--query-gpu=power.draw", "--format=csv,noheader,nounits", "-i", address)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if w, err := strconv.ParseFloat(strings.TrimSpace(out), 64); err == nil {
|
||||
total += w
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
return &total
|
||||
}
|
||||
|
||||
// Sample is one reading over `every`: the battery's discharge if the machine is on battery, else the
|
||||
// processor's and the graphics' parts.
|
||||
func (m *Machine) Sample(ctx context.Context, every time.Duration, wait func(time.Duration) bool) Draw {
|
||||
if w := m.batteryWatts(); w != nil {
|
||||
return Draw{Machine: w}
|
||||
}
|
||||
before, start := m.packageCounters(), time.Now()
|
||||
if !wait(every) {
|
||||
return Draw{}
|
||||
}
|
||||
return Draw{CPU: cpuWatts(before, m.packageCounters(), time.Since(start)), GPU: m.gpuWatts(ctx)}
|
||||
}
|
||||
|
||||
// Publish writes the 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
|
||||
}
|
||||
tmp := path + ".new"
|
||||
if err := os.WriteFile(tmp, []byte(line+"\n"), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// RunDraw samples and publishes until the context ends.
|
||||
func RunDraw(ctx context.Context, m *Machine) error {
|
||||
return runDraw(ctx, m, SampleEvery, m.Publish)
|
||||
}
|
||||
|
||||
// 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(Reading) error) error {
|
||||
wait := func(d time.Duration) bool {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(d):
|
||||
return true
|
||||
}
|
||||
}
|
||||
for ctx.Err() == nil {
|
||||
d := m.Sample(ctx, every, wait)
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if err := publish(d.Reading(time.Now())); err != nil {
|
||||
return fmt.Errorf("publishing the draw: %w", err)
|
||||
}
|
||||
if d.Machine != nil && !wait(every) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0255: the power drawn is measured honestly and says what it measured — the
|
||||
// machine on battery, the processor (and discrete graphics) otherwise — and only a rate leaves root.
|
||||
|
||||
func tree(t *testing.T, files map[string]string) *Machine {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for p, c := range files {
|
||||
full := filepath.Join(root, p)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(c+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return &Machine{Root: root, Run: func(context.Context, string, ...string) (string, error) {
|
||||
return "", errors.New("nothing may be run here")
|
||||
}}
|
||||
}
|
||||
|
||||
func noWait(time.Duration) bool { return true }
|
||||
|
||||
func TestOnBatteryTheDischargeIsTheMachine(t *testing.T) {
|
||||
m := tree(t, map[string]string{
|
||||
"/sys/class/power_supply/BAT1/type": "Battery",
|
||||
"/sys/class/power_supply/BAT1/status": "Discharging",
|
||||
"/sys/class/power_supply/BAT1/current_now": "-1500000",
|
||||
"/sys/class/power_supply/BAT1/voltage_now": "16000000",
|
||||
// A counter is there, and not what a battery reading reports.
|
||||
"/sys/class/powercap/intel-rapl:0/name": "package-0",
|
||||
"/sys/class/powercap/intel-rapl:0/energy_uj": "1",
|
||||
})
|
||||
d := m.Sample(context.Background(), time.Second, func(time.Duration) bool { t.Fatal("a battery reading waited"); return false })
|
||||
if got := d.Line(); got != "24.0 W machine" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnMainsTheProcessorIsMeasuredAndCalledTheProcessor(t *testing.T) {
|
||||
m := tree(t, map[string]string{
|
||||
"/sys/class/power_supply/BAT1/type": "Battery",
|
||||
"/sys/class/power_supply/BAT1/status": "Not charging",
|
||||
"/sys/class/powercap/intel-rapl:0/name": "package-0",
|
||||
"/sys/class/powercap/intel-rapl:0/energy_uj": "1000000",
|
||||
"/sys/class/powercap/intel-rapl:0/max_energy_range_uj": "65532610987",
|
||||
"/sys/class/powercap/intel-rapl:0:0/name": "core",
|
||||
"/sys/class/powercap/intel-rapl:0:0/energy_uj": "1",
|
||||
// An APU's integrated graphics report the whole package: not counted a second time.
|
||||
"/sys/class/drm/card1/device/hwmon/hwmon5/power1_average": "19000000",
|
||||
})
|
||||
counter := filepath.Join(m.Root, "/sys/class/powercap/intel-rapl:0/energy_uj")
|
||||
// 1 J over 50 ms: 20 W, well inside what a package draws.
|
||||
d := m.Sample(context.Background(), time.Second, func(time.Duration) bool {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return os.WriteFile(counter, []byte("2000000\n"), 0o644) == nil
|
||||
})
|
||||
if d.Machine != nil || d.CPU == nil || d.GPU != nil || *d.CPU <= 0 {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
if !strings.HasSuffix(d.Line(), " W CPU") {
|
||||
t.Fatalf("%q", d.Line())
|
||||
}
|
||||
}
|
||||
|
||||
func TestACounterThatWrappedIsCountedAcrossItsRange(t *testing.T) {
|
||||
before := map[string]counter{"p": {uj: 90, max: 100}}
|
||||
after := map[string]counter{"p": {uj: 10, max: 100}}
|
||||
w := cpuWatts(before, after, time.Second)
|
||||
if w == nil || *w != 20e-6 {
|
||||
t.Fatalf("%v", w)
|
||||
}
|
||||
if cpuWatts(before, map[string]counter{"other": {uj: 1}}, time.Second) != nil {
|
||||
t.Fatal("a counter read once was taken for a rate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestADiscreteGraphicsDevicesPowerIsAddedAndASleepingOneIsNotWoken(t *testing.T) {
|
||||
m := tree(t, map[string]string{
|
||||
"/sys/class/drm/card0/device/mem_info_vram_vendor": "samsung",
|
||||
"/sys/class/drm/card0/device/hwmon/hwmon3/power1_average": "30000000",
|
||||
"/sys/class/drm/card0-DP-1/status": "connected",
|
||||
"/proc/driver/nvidia/gpus/0000:01:00.0/information": "x",
|
||||
"/sys/bus/pci/devices/0000:01:00.0/power/runtime_status": "suspended",
|
||||
})
|
||||
if w := m.gpuWatts(context.Background()); w == nil || *w != 30 {
|
||||
t.Fatalf("%v", w)
|
||||
}
|
||||
// Awake, it is asked.
|
||||
if err := os.WriteFile(filepath.Join(m.Root, "/sys/bus/pci/devices/0000:01:00.0/power/runtime_status"), []byte("active\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if name != "nvidia-smi" || args[len(args)-1] != "0000:01:00.0" {
|
||||
t.Fatalf("ran %s %v", name, args)
|
||||
}
|
||||
return "12.50\n", nil
|
||||
}
|
||||
if w := m.gpuWatts(context.Background()); w == nil || *w != 42.5 {
|
||||
t.Fatalf("%v", w)
|
||||
}
|
||||
cpu, gpu := 10.0, 42.5
|
||||
if got := (Draw{CPU: &cpu, GPU: &gpu}).Line(); got != "52.5 W CPU+GPU" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingToMeasureIsSaid(t *testing.T) {
|
||||
m := tree(t, map[string]string{"/sys/class/power_supply/ACAD/type": "Mains"})
|
||||
if got := m.Sample(context.Background(), time.Second, noWait).Line(); got != "power not measured" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyTheRateIsLeftForTheBundle(t *testing.T) {
|
||||
m := tree(t, 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))
|
||||
if err != nil || info.Mode().Perm() != 0o644 {
|
||||
t.Fatalf("%v %v", info, err)
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(m.Root, DrawFile))
|
||||
if string(b) != `{"text":"8.0 W CPU","watts":8,"measures":"cpu","at":"2026-10-08T12:00:00Z"}`+"\n" {
|
||||
t.Fatalf("%q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPackageSeenTwiceIsCountedOnce(t *testing.T) {
|
||||
m := tree(t, map[string]string{
|
||||
"/sys/class/powercap/intel-rapl:0/name": "package-0",
|
||||
"/sys/class/powercap/intel-rapl:0/energy_uj": "100",
|
||||
"/sys/class/powercap/intel-rapl-mmio:0/name": "package-0",
|
||||
"/sys/class/powercap/intel-rapl-mmio:0/energy_uj": "999",
|
||||
"/sys/class/powercap/intel-rapl:1/name": "package-1",
|
||||
"/sys/class/powercap/intel-rapl:1/energy_uj": "5",
|
||||
})
|
||||
got := m.packageCounters()
|
||||
if len(got) != 2 || got["package-0"].uj != 100 || got["package-1"].uj != 5 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACounterResetGivesNoReading(t *testing.T) {
|
||||
// After a resume the counter starts again near nothing: across its range that would be thousands
|
||||
// of watts, which is no reading at all.
|
||||
before := map[string]counter{"package-0": {uj: 40_000_000_000, max: 65_532_610_987}}
|
||||
after := map[string]counter{"package-0": {uj: 1_000_000, max: 65_532_610_987}}
|
||||
if w := cpuWatts(before, after, 5*time.Second); w != nil {
|
||||
t.Fatalf("a reset counter read as %.0f W", *w)
|
||||
}
|
||||
// A true wrap, near the end of the range, is still counted.
|
||||
before = map[string]counter{"package-0": {uj: 65_532_610_987 - 10_000_000, max: 65_532_610_987}}
|
||||
after = map[string]counter{"package-0": {uj: 40_000_000, max: 65_532_610_987}}
|
||||
if w := cpuWatts(before, after, 5*time.Second); w == nil || *w != 10 {
|
||||
t.Fatalf("a wrapped counter read as %v", w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASleepingAMDCardIsNotRead(t *testing.T) {
|
||||
m := tree(t, map[string]string{
|
||||
"/sys/class/drm/card0/device/mem_info_vram_vendor": "samsung",
|
||||
"/sys/class/drm/card0/device/power/runtime_status": "suspended",
|
||||
"/sys/class/drm/card0/device/hwmon/hwmon3/power1_average": "30000000",
|
||||
})
|
||||
if w := m.gpuWatts(context.Background()); w != nil {
|
||||
t.Fatalf("a suspended card was read: %v", *w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLoopPublishesEveryIntervalAndStopsWhenCancelled(t *testing.T) {
|
||||
m := tree(t, map[string]string{
|
||||
"/sys/class/power_supply/BAT0/type": "Battery",
|
||||
"/sys/class/power_supply/BAT0/status": "Discharging",
|
||||
"/sys/class/power_supply/BAT0/power_now": "12000000",
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var lines []string
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
done <- runDraw(ctx, m, 20*time.Millisecond, func(r Reading) error { lines = append(lines, r.Text); return nil })
|
||||
}()
|
||||
time.Sleep(210 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("the loop did not stop when cancelled")
|
||||
}
|
||||
// One at once, then one every 20 ms: about eleven in 210 ms, never a burst, never a stall.
|
||||
if len(lines) < 6 || len(lines) > 12 || lines[0] != "12.0 W machine" {
|
||||
t.Fatalf("%d lines: %v", len(lines), lines)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,42 @@
|
||||
// The power module's Go bundle (novox/hq ADR 0211, ADR 0198): one process the node's runtime
|
||||
// launches, serving the module's tools over MCP on stdio and running its watcher beside them, which
|
||||
// publishes the machine's power states on the bus.
|
||||
//
|
||||
// Started as `power draw` it is the module's other process instead: the sampler that publishes the
|
||||
// power the machine draws for the bar, as root, because the energy counter is root's (draw.go).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
if os.Args[1] != "draw" || len(os.Args) != 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: power [draw]")
|
||||
os.Exit(2)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
||||
defer stop()
|
||||
if err := RunDraw(ctx, Here()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
m := Here()
|
||||
w := NewWatcher(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) }, DialLogin1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,13 @@
|
||||
"on-battery",
|
||||
"battery-low"
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
"name": "draw",
|
||||
"ttl-seconds": 30,
|
||||
"per-machine": true
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"power_state",
|
||||
"power_hooks",
|
||||
@@ -234,6 +241,25 @@
|
||||
"unit": "mesh-power-before-shutdown.service",
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
},
|
||||
{
|
||||
"id": "draw-dir",
|
||||
"type": "directory",
|
||||
"path": "/run/mesh-power",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "draw",
|
||||
"type": "process",
|
||||
"name": "mesh-power-draw",
|
||||
"artifact": "tools-go",
|
||||
"run": [
|
||||
"./power",
|
||||
"draw"
|
||||
],
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -255,5 +281,32 @@
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"contributions": [
|
||||
{
|
||||
"seat": "node-bar",
|
||||
"kind": "block",
|
||||
"if-capability": "battery",
|
||||
"data": {
|
||||
"bar": "bottom",
|
||||
"place": "status",
|
||||
"order": 40,
|
||||
"shows": "battery"
|
||||
}
|
||||
},
|
||||
{
|
||||
"seat": "node-bar",
|
||||
"kind": "block",
|
||||
"if-capability": "power-meter",
|
||||
"data": {
|
||||
"bar": "bottom",
|
||||
"place": "status",
|
||||
"order": 45,
|
||||
"shows": "state",
|
||||
"options": {
|
||||
"state": "power.draw"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user