Let the bar render blocks other modules contribute, and give it the battery and power draw from the power module (hq ADR 0255)
The bar left the battery out because it belongs to whoever knows the hardware, and had no way to take it back in. i3status-rust now renders bar-neutral blocks through its template; the power module contributes the battery where the machine reports one and the power draw where something measures it, sampling the root-only energy counter as root and publishing only a rate. The memory block shows what programs hold, the figure memory-pressure judges by, instead of counting the cache as used.
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The power the machine draws, measured honestly and said with what was measured (novox/hq ADR 0255).
|
||||
//
|
||||
// - **On battery,** the battery's discharge rate. That is the whole machine.
|
||||
// - **Otherwise** — on mains, or on a machine without a battery — the processor package's power from
|
||||
// the kernel's powercap energy counter (RAPL, on Intel and AMD alike: a rate from the counter's
|
||||
// change), plus a discrete graphics device's own power where it reports one. That is the processor
|
||||
// (and graphics) part, never called the machine's.
|
||||
// - **The machine at the wall** needs a metered plug or a UPS, and is not measured here.
|
||||
//
|
||||
// The energy counter is root's to read, on purpose: read often, it leaks what the processor is doing
|
||||
// (the PLATYPUS attack). So the counter is never opened to the operator's account. `power draw` runs as
|
||||
// root, as the module's process, samples every SampleEvery, and writes only the averaged rate, one line,
|
||||
// to DrawFile — readable by anyone, and too coarse to leak anything. The bar's block prints that line.
|
||||
|
||||
// DrawFile is where the rate is published, and SampleEvery how often.
|
||||
const (
|
||||
DrawFile = "/run/mesh-power/draw"
|
||||
SampleEvery = 5 * time.Second
|
||||
)
|
||||
|
||||
// Draw is one reading: each part measured, in watts, and what it covers.
|
||||
type Draw struct {
|
||||
// Machine is the battery's discharge, when on battery: the whole machine.
|
||||
Machine *float64
|
||||
// CPU is the processor packages' power, from the energy counters.
|
||||
CPU *float64
|
||||
// GPU is the discrete graphics devices' own power, where they report it; asleep counts as none.
|
||||
GPU *float64
|
||||
}
|
||||
|
||||
// Line is the reading as the bar shows it.
|
||||
func (d Draw) Line() string {
|
||||
switch {
|
||||
case d.Machine != nil:
|
||||
return fmt.Sprintf("%.1f W machine", *d.Machine)
|
||||
case d.CPU != nil && d.GPU != nil:
|
||||
return fmt.Sprintf("%.1f W CPU+GPU", *d.CPU+*d.GPU)
|
||||
case d.CPU != nil:
|
||||
return fmt.Sprintf("%.1f W CPU", *d.CPU)
|
||||
case d.GPU != nil:
|
||||
return fmt.Sprintf("%.1f W GPU", *d.GPU)
|
||||
}
|
||||
return "power not measured"
|
||||
}
|
||||
|
||||
// counter is one energy counter's reading, in microjoules, with the range it wraps at.
|
||||
type counter struct {
|
||||
uj, max int64
|
||||
}
|
||||
|
||||
// packageCounters is every processor package's energy counter, by its directory.
|
||||
func (m *Machine) packageCounters() map[string]counter {
|
||||
out := map[string]counter{}
|
||||
for _, dir := range m.glob("/sys/class/powercap/*") {
|
||||
if !strings.HasPrefix(m.read(dir+"/name"), "package") {
|
||||
continue
|
||||
}
|
||||
uj, ok := m.readInt(dir + "/energy_uj")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
max, _ := m.readInt(dir + "/max_energy_range_uj")
|
||||
out[dir] = counter{uj: uj, max: max}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cpuWatts is the packages' power between two readings `elapsed` apart; nil when no counter was read
|
||||
// both times. A counter that wrapped is counted across its range.
|
||||
func cpuWatts(before, after map[string]counter, elapsed time.Duration) *float64 {
|
||||
if elapsed <= 0 {
|
||||
return nil
|
||||
}
|
||||
var joules float64
|
||||
read := false
|
||||
for dir, a := range after {
|
||||
b, ok := before[dir]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delta := a.uj - b.uj
|
||||
if delta < 0 {
|
||||
if a.max <= 0 {
|
||||
continue
|
||||
}
|
||||
delta += a.max
|
||||
}
|
||||
joules += float64(delta) / 1e6
|
||||
read = true
|
||||
}
|
||||
if !read {
|
||||
return nil
|
||||
}
|
||||
w := joules / elapsed.Seconds()
|
||||
return &w
|
||||
}
|
||||
|
||||
// batteryWatts is the system battery's discharge, when it is discharging; nil otherwise.
|
||||
func (m *Machine) batteryWatts() *float64 {
|
||||
var total float64
|
||||
found := false
|
||||
for _, s := range m.Supplies() {
|
||||
if s.Type != "Battery" || s.Device || s.Status != "Discharging" {
|
||||
continue
|
||||
}
|
||||
dir := "/sys/class/power_supply/" + s.Name
|
||||
if p, ok := m.readInt(dir + "/power_now"); ok {
|
||||
total += float64(abs(p)) / 1e6
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
i, iok := m.readInt(dir + "/current_now")
|
||||
v, vok := m.readInt(dir + "/voltage_now")
|
||||
if iok && vok {
|
||||
total += float64(abs(i)) * float64(v) / 1e12
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
return &total
|
||||
}
|
||||
|
||||
func abs(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// gpuWatts is the discrete graphics devices' own power, where they report it; nil when none does.
|
||||
//
|
||||
// - An AMD device's hwmon power counts only when it has its own memory (`mem_info_vram_vendor`): on
|
||||
// an APU the integrated graphics report the whole package, which the processor's counter already
|
||||
// holds, and counting it twice would be a lie.
|
||||
// - An NVIDIA device is asked through nvidia-smi only while it is awake: asking wakes a suspended GPU,
|
||||
// which on a hybrid laptop costs more than the answer is worth. Asleep, it draws next to nothing and
|
||||
// counts as none.
|
||||
func (m *Machine) gpuWatts(ctx context.Context) *float64 {
|
||||
var total float64
|
||||
found := false
|
||||
for _, card := range m.glob("/sys/class/drm/card[0-9]*") {
|
||||
if strings.Contains(filepath.Base(card), "-") || !m.exists(card+"/device/mem_info_vram_vendor") {
|
||||
continue
|
||||
}
|
||||
for _, hw := range m.glob(card + "/device/hwmon/hwmon*") {
|
||||
uw, ok := m.readInt(hw + "/power1_average")
|
||||
if !ok {
|
||||
uw, ok = m.readInt(hw + "/power1_input")
|
||||
}
|
||||
if ok {
|
||||
total += float64(uw) / 1e6
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, gpu := range m.glob("/proc/driver/nvidia/gpus/*") {
|
||||
address := filepath.Base(gpu)
|
||||
if m.read("/sys/bus/pci/devices/"+address+"/power/runtime_status") != "active" {
|
||||
continue
|
||||
}
|
||||
out, err := m.Run(ctx, "nvidia-smi", "--query-gpu=power.draw", "--format=csv,noheader,nounits", "-i", address)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if w, err := strconv.ParseFloat(strings.TrimSpace(out), 64); err == nil {
|
||||
total += w
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
return &total
|
||||
}
|
||||
|
||||
// Sample is one reading over `every`: the battery's discharge if the machine is on battery, else the
|
||||
// processor's and the graphics' parts.
|
||||
func (m *Machine) Sample(ctx context.Context, every time.Duration, wait func(time.Duration) bool) Draw {
|
||||
if w := m.batteryWatts(); w != nil {
|
||||
return Draw{Machine: w}
|
||||
}
|
||||
before, start := m.packageCounters(), time.Now()
|
||||
if !wait(every) {
|
||||
return Draw{}
|
||||
}
|
||||
return Draw{CPU: cpuWatts(before, m.packageCounters(), time.Since(start)), GPU: m.gpuWatts(ctx)}
|
||||
}
|
||||
|
||||
// Publish writes the line where the bar reads it: whole, readable by anyone, replacing the last.
|
||||
func (m *Machine) Publish(line string) error {
|
||||
path := m.path(DrawFile)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".new"
|
||||
if err := os.WriteFile(tmp, []byte(line+"\n"), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// RunDraw samples and publishes until the context ends. A battery reading is instant, so it waits
|
||||
// the interval itself; a counter reading waits inside its sample.
|
||||
func RunDraw(ctx context.Context, m *Machine) error {
|
||||
wait := func(d time.Duration) bool {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(d):
|
||||
return true
|
||||
}
|
||||
}
|
||||
for ctx.Err() == nil {
|
||||
d := m.Sample(ctx, SampleEvery, wait)
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if err := m.Publish(d.Line()); err != nil {
|
||||
return fmt.Errorf("publishing the draw: %w", err)
|
||||
}
|
||||
if d.Machine != nil && !wait(SampleEvery) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0255: the power drawn is measured honestly and says what it measured — the
|
||||
// machine on battery, the processor (and discrete graphics) otherwise — and only a rate leaves root.
|
||||
|
||||
func tree(t *testing.T, files map[string]string) *Machine {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for p, c := range files {
|
||||
full := filepath.Join(root, p)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(c+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return &Machine{Root: root, Run: func(context.Context, string, ...string) (string, error) {
|
||||
return "", errors.New("nothing may be run here")
|
||||
}}
|
||||
}
|
||||
|
||||
func noWait(time.Duration) bool { return true }
|
||||
|
||||
func TestOnBatteryTheDischargeIsTheMachine(t *testing.T) {
|
||||
m := tree(t, map[string]string{
|
||||
"/sys/class/power_supply/BAT1/type": "Battery",
|
||||
"/sys/class/power_supply/BAT1/status": "Discharging",
|
||||
"/sys/class/power_supply/BAT1/current_now": "-1500000",
|
||||
"/sys/class/power_supply/BAT1/voltage_now": "16000000",
|
||||
// A counter is there, and not what a battery reading reports.
|
||||
"/sys/class/powercap/intel-rapl:0/name": "package-0",
|
||||
"/sys/class/powercap/intel-rapl:0/energy_uj": "1",
|
||||
})
|
||||
d := m.Sample(context.Background(), time.Second, func(time.Duration) bool { t.Fatal("a battery reading waited"); return false })
|
||||
if got := d.Line(); got != "24.0 W machine" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnMainsTheProcessorIsMeasuredAndCalledTheProcessor(t *testing.T) {
|
||||
m := tree(t, map[string]string{
|
||||
"/sys/class/power_supply/BAT1/type": "Battery",
|
||||
"/sys/class/power_supply/BAT1/status": "Not charging",
|
||||
"/sys/class/powercap/intel-rapl:0/name": "package-0",
|
||||
"/sys/class/powercap/intel-rapl:0/energy_uj": "1000000",
|
||||
"/sys/class/powercap/intel-rapl:0/max_energy_range_uj": "65532610987",
|
||||
"/sys/class/powercap/intel-rapl:0:0/name": "core",
|
||||
"/sys/class/powercap/intel-rapl:0:0/energy_uj": "1",
|
||||
// An APU's integrated graphics report the whole package: not counted a second time.
|
||||
"/sys/class/drm/card1/device/hwmon/hwmon5/power1_average": "19000000",
|
||||
})
|
||||
counter := filepath.Join(m.Root, "/sys/class/powercap/intel-rapl:0/energy_uj")
|
||||
d := m.Sample(context.Background(), time.Second, func(time.Duration) bool {
|
||||
return os.WriteFile(counter, []byte("11000000\n"), 0o644) == nil
|
||||
})
|
||||
if d.Machine != nil || d.CPU == nil || d.GPU != nil || *d.CPU <= 0 {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
if !strings.HasSuffix(d.Line(), " W CPU") {
|
||||
t.Fatalf("%q", d.Line())
|
||||
}
|
||||
}
|
||||
|
||||
func TestACounterThatWrappedIsCountedAcrossItsRange(t *testing.T) {
|
||||
before := map[string]counter{"p": {uj: 90, max: 100}}
|
||||
after := map[string]counter{"p": {uj: 10, max: 100}}
|
||||
w := cpuWatts(before, after, time.Second)
|
||||
if w == nil || *w != 20e-6 {
|
||||
t.Fatalf("%v", w)
|
||||
}
|
||||
if cpuWatts(before, map[string]counter{"other": {uj: 1}}, time.Second) != nil {
|
||||
t.Fatal("a counter read once was taken for a rate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestADiscreteGraphicsDevicesPowerIsAddedAndASleepingOneIsNotWoken(t *testing.T) {
|
||||
m := tree(t, map[string]string{
|
||||
"/sys/class/drm/card0/device/mem_info_vram_vendor": "samsung",
|
||||
"/sys/class/drm/card0/device/hwmon/hwmon3/power1_average": "30000000",
|
||||
"/sys/class/drm/card0-DP-1/status": "connected",
|
||||
"/proc/driver/nvidia/gpus/0000:01:00.0/information": "x",
|
||||
"/sys/bus/pci/devices/0000:01:00.0/power/runtime_status": "suspended",
|
||||
})
|
||||
if w := m.gpuWatts(context.Background()); w == nil || *w != 30 {
|
||||
t.Fatalf("%v", w)
|
||||
}
|
||||
// Awake, it is asked.
|
||||
if err := os.WriteFile(filepath.Join(m.Root, "/sys/bus/pci/devices/0000:01:00.0/power/runtime_status"), []byte("active\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if name != "nvidia-smi" || args[len(args)-1] != "0000:01:00.0" {
|
||||
t.Fatalf("ran %s %v", name, args)
|
||||
}
|
||||
return "12.50\n", nil
|
||||
}
|
||||
if w := m.gpuWatts(context.Background()); w == nil || *w != 42.5 {
|
||||
t.Fatalf("%v", w)
|
||||
}
|
||||
cpu, gpu := 10.0, 42.5
|
||||
if got := (Draw{CPU: &cpu, GPU: &gpu}).Line(); got != "52.5 W CPU+GPU" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingToMeasureIsSaid(t *testing.T) {
|
||||
m := tree(t, map[string]string{"/sys/class/power_supply/ACAD/type": "Mains"})
|
||||
if got := m.Sample(context.Background(), time.Second, noWait).Line(); got != "power not measured" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyTheRateIsPublishedReadableByAnyone(t *testing.T) {
|
||||
m := tree(t, nil)
|
||||
if err := m.Publish("8.0 W CPU"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(m.Root, DrawFile))
|
||||
if err != nil || info.Mode().Perm() != 0o644 {
|
||||
t.Fatalf("%v %v", info, err)
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(m.Root, DrawFile))
|
||||
if string(b) != "8.0 W CPU\n" {
|
||||
t.Fatalf("%q", b)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,35 @@
|
||||
// The power module's Go bundle (novox/hq ADR 0211, ADR 0198): one process the node's runtime
|
||||
// launches, serving the module's tools over MCP on stdio and running its watcher beside them, which
|
||||
// publishes the machine's power states on the bus.
|
||||
//
|
||||
// Started as `power draw` it is the module's other process instead: the sampler that publishes the
|
||||
// power the machine draws for the bar, as root, because the energy counter is root's (draw.go).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
if os.Args[1] != "draw" || len(os.Args) != 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: power [draw]")
|
||||
os.Exit(2)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
||||
defer stop()
|
||||
if err := RunDraw(ctx, Here()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
m := Here()
|
||||
w := NewWatcher(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) }, DialLogin1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
Reference in New Issue
Block a user