Files
mesh-catalog/modules/power/cmd/power/draw.go
T
jochen 76b63744e6 Carry the power draw to the bar over the bus, not through a file (hq ADR 0260)
Modules talk over the bus. The power module's bundle puts each fresh reading of its own
sampler in its state draw, keyed by the machine; the bar's block shows that state, which
the bar's bundle watches under the read the mesh grants it. A new catalogue check refuses
a module naming another module's path under /run or /var/lib.
2026-10-08 16:12:37 +02:00

302 lines
9.3 KiB
Go

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
}