diff --git a/internal/profile/detectors.go b/internal/profile/detectors.go index 7e76af6..604f49d 100644 --- a/internal/profile/detectors.go +++ b/internal/profile/detectors.go @@ -187,6 +187,8 @@ func Default(runner Runner) []Detector { privileged{}, graphicalSession{}, seat{}, + battery{}, + powerMeter{}, commandCapability{ name: CapContainerRuntime, command: "docker", args: []string{"info", "--format", "{{.ServerVersion}}"}, why: "asks the daemon for its version — a running daemon, not an installed client", diff --git a/internal/profile/power.go b/internal/profile/power.go new file mode 100644 index 0000000..48efcc6 --- /dev/null +++ b/internal/profile/power.go @@ -0,0 +1,135 @@ +package profile + +import ( + "context" + "os" + "path/filepath" + "sort" + "strings" +) + +// What this machine has to say about its own power (novox/hq ADR 0255): whether it has a battery, +// and whether anything on it can measure the power it draws. +// +// **Hardware, read from the kernel, as `seat` is.** A module that shows a battery, or the power a +// machine draws, is on every machine — the power module holds node-power everywhere — and what it +// shows depends on what is there. The controller composes such a piece only where the machine +// reported the capability, so a desktop never gets a battery block that would sit empty, and the +// bar never asks at run time what the mesh could have said once. + +// Named power capabilities. +const ( + // CapBattery is a battery that powers this machine: a power supply of type Battery that is not + // a device's (a mouse's or a headset's battery has scope Device). + CapBattery = "battery" + // CapPowerMeter is something here that measures the power the machine, or part of it, draws: + // the machine's battery, the processor package's energy counter (powercap, Intel's and AMD's + // RAPL), or a discrete graphics device that reports its own power — the same meters the power + // module's sampler reads, so a machine never gets a block that can only say "not measured". + CapPowerMeter = "power-meter" +) + +// Where the kernel reports supplies and energy counters. Variables so a test can point them at a +// directory it made, rather than at whatever this machine happens to have. +var ( + PowerSupplySource = "/sys/class/power_supply" + PowercapSource = "/sys/class/powercap" + NvidiaSource = "/proc/driver/nvidia/gpus" +) + +// systemBatteries is every battery that powers this machine, by name. +func systemBatteries() []string { + entries, err := os.ReadDir(PowerSupplySource) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + dir := filepath.Join(PowerSupplySource, e.Name()) + if readTrimmed(filepath.Join(dir, "type")) != "Battery" { + continue + } + if readTrimmed(filepath.Join(dir, "scope")) == "Device" { + continue + } + // A bay with no battery in it still has a directory. + if readTrimmed(filepath.Join(dir, "present")) == "0" { + continue + } + out = append(out, e.Name()) + } + sort.Strings(out) + return out +} + +func readTrimmed(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +type battery struct{} + +func (battery) Name() string { return CapBattery } + +func (battery) Detect(context.Context) Verdict { + const how = "/sys/class/power_supply/*/type — a Battery whose scope is not Device" + if found := systemBatteries(); len(found) > 0 { + return Verdict{Name: CapBattery, Present: true, Detail: strings.Join(found, ", "), How: how} + } + return Verdict{Name: CapBattery, Present: false, Detail: "no battery powers this machine", How: how} +} + +type powerMeter struct{} + +func (powerMeter) Name() string { return CapPowerMeter } + +// Detect names every meter it found. Only that one exists: whether its counter can be read is the +// reader's question — powercap's energy counter is root's to read, and the power module samples it +// as root and publishes only a rate. +func (powerMeter) Detect(context.Context) Verdict { + const how = "a system battery; /sys/class/powercap/*/name starting package, each name once; a discrete " + + "graphics device's (mem_info_vram_vendor) hwmon power1_average or power1_input; /proc/driver/nvidia/gpus" + var found []string + for _, b := range systemBatteries() { + found = append(found, "battery "+b) + } + // Each package once by its name: Intel shows some twice, as intel-rapl:N and intel-rapl-mmio:N. + if entries, err := os.ReadDir(PowercapSource); err == nil { + seen := map[string]bool{} + for _, e := range entries { + dir := filepath.Join(PowercapSource, e.Name()) + name := readTrimmed(filepath.Join(dir, "name")) + if !strings.HasPrefix(name, "package") || seen[name] { + continue + } + if _, err := os.Stat(filepath.Join(dir, "energy_uj")); err == nil { + seen[name] = true + found = append(found, "processor "+name) + } + } + } + if cards, err := filepath.Glob(filepath.Join(SeatSource, "card*", "device", "hwmon", "hwmon*")); err == nil { + for _, hw := range cards { + // Integrated graphics report the package the processor's counter already holds. + if _, err := os.Stat(filepath.Join(filepath.Dir(filepath.Dir(hw)), "mem_info_vram_vendor")); err != nil { + continue + } + for _, f := range []string{"power1_average", "power1_input"} { + if _, err := os.Stat(filepath.Join(hw, f)); err == nil { + found = append(found, "graphics "+strings.Split(strings.TrimPrefix(hw, SeatSource+"/"), "/")[0]) + break + } + } + } + } + if gpus, err := os.ReadDir(NvidiaSource); err == nil && len(gpus) > 0 { + found = append(found, "graphics nvidia") + } + if len(found) == 0 { + return Verdict{Name: CapPowerMeter, Present: false, Detail: "nothing here measures the power drawn", How: how} + } + return Verdict{Name: CapPowerMeter, Present: true, Detail: strings.Join(found, ", "), How: how} +} diff --git a/internal/profile/power_test.go b/internal/profile/power_test.go new file mode 100644 index 0000000..aebea24 --- /dev/null +++ b/internal/profile/power_test.go @@ -0,0 +1,133 @@ +package profile + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// Defends novox/hq ADR 0255: a machine says whether a battery powers it and whether anything on it +// measures the power it draws, read from the kernel, so the controller composes a battery's or a power +// draw's block only where there is one. + +func write(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content+"\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +// sysfs points every power source at a directory of its own, empty unless a case fills it. +func sysfs(t *testing.T) string { + t.Helper() + dir := t.TempDir() + old := [4]string{PowerSupplySource, PowercapSource, SeatSource, NvidiaSource} + PowerSupplySource = filepath.Join(dir, "power_supply") + PowercapSource = filepath.Join(dir, "powercap") + SeatSource = filepath.Join(dir, "drm") + NvidiaSource = filepath.Join(dir, "nvidia") + t.Cleanup(func() { PowerSupplySource, PowercapSource, SeatSource, NvidiaSource = old[0], old[1], old[2], old[3] }) + return dir +} + +func TestALaptopHasABatteryAndAMeter(t *testing.T) { + dir := sysfs(t) + write(t, dir+"/power_supply/ACAD/type", "Mains") + write(t, dir+"/power_supply/BAT1/type", "Battery") + write(t, dir+"/power_supply/BAT1/scope", "System") + write(t, dir+"/power_supply/BAT1/present", "1") + write(t, dir+"/powercap/intel-rapl:0/name", "package-0") + write(t, dir+"/powercap/intel-rapl:0/energy_uj", "1") + + b := battery{}.Detect(context.Background()) + if !b.Present || b.Detail != "BAT1" { + t.Fatalf("battery: %+v", b) + } + m := powerMeter{}.Detect(context.Background()) + if !m.Present || m.Detail != "battery BAT1, processor package-0" { + t.Fatalf("power meter: %+v", m) + } +} + +func TestADevicesBatteryIsNotTheMachines(t *testing.T) { + // The case worth a test: a desktop with a wireless mouse reports a Battery, and a battery block + // composed for it would show the mouse's charge as the machine's. + dir := sysfs(t) + write(t, dir+"/power_supply/hidpp_battery_0/type", "Battery") + write(t, dir+"/power_supply/hidpp_battery_0/scope", "Device") + write(t, dir+"/power_supply/BAT0/type", "Battery") + write(t, dir+"/power_supply/BAT0/present", "0") + + if b := (battery{}).Detect(context.Background()); b.Present { + t.Fatalf("a mouse's battery, or an empty bay, was taken for the machine's: %+v", b) + } + if m := (powerMeter{}).Detect(context.Background()); m.Present { + t.Fatalf("a device's battery was taken for a meter: %+v", m) + } +} + +func TestADesktopMeasuresItsProcessorAndItsGraphics(t *testing.T) { + dir := sysfs(t) + write(t, dir+"/powercap/intel-rapl:0/name", "package-0") + write(t, dir+"/powercap/intel-rapl:0/energy_uj", "1") + // A sub-domain is part of the package, not a second meter. + write(t, dir+"/powercap/intel-rapl:0:0/name", "core") + write(t, dir+"/powercap/intel-rapl:0:0/energy_uj", "1") + write(t, dir+"/drm/card1/device/hwmon/hwmon4/power1_average", "8000000") + write(t, dir+"/drm/card1/device/mem_info_vram_vendor", "samsung") + write(t, dir+"/nvidia/0000:01:00.0/information", "x") + + if b := (battery{}).Detect(context.Background()); b.Present { + t.Fatalf("a desktop has a battery: %+v", b) + } + m := powerMeter{}.Detect(context.Background()) + if !m.Present || m.Detail != "processor package-0, graphics card1, graphics nvidia" { + t.Fatalf("power meter: %+v", m) + } +} + +func TestAMachineWithNothingToReadHasNeither(t *testing.T) { + sysfs(t) + b, m := battery{}.Detect(context.Background()), powerMeter{}.Detect(context.Background()) + if b.Present || m.Present || b.How == "" || m.How == "" || !strings.Contains(m.Detail, "nothing") { + t.Fatalf("%+v %+v", b, m) + } +} + +func TestAPackageShownTwiceIsOneMeterAndIntegratedGraphicsAreNone(t *testing.T) { + dir := sysfs(t) + write(t, dir+"/powercap/intel-rapl:0/name", "package-0") + write(t, dir+"/powercap/intel-rapl:0/energy_uj", "1") + write(t, dir+"/powercap/intel-rapl-mmio:0/name", "package-0") + write(t, dir+"/powercap/intel-rapl-mmio:0/energy_uj", "1") + // An APU's graphics: power reported, no memory of its own. + write(t, dir+"/drm/card1/device/hwmon/hwmon5/power1_average", "19000000") + m := powerMeter{}.Detect(context.Background()) + if !m.Present || m.Detail != "processor package-0" { + t.Fatalf("power meter: %+v", m) + } +} + +func TestOnlyIntegratedGraphicsMeasureNothing(t *testing.T) { + dir := sysfs(t) + write(t, dir+"/drm/card0/device/hwmon/hwmon1/power1_input", "5000000") + if m := (powerMeter{}).Detect(context.Background()); m.Present { + t.Fatalf("integrated graphics were taken for a meter: %+v", m) + } +} + +func TestABatteryOfUnknownScopeIsTheMachines(t *testing.T) { + // Many laptops' firmware says nothing of scope; the kernel reports Unknown. Only Device is a + // peripheral's. + dir := sysfs(t) + write(t, dir+"/power_supply/BAT0/type", "Battery") + write(t, dir+"/power_supply/BAT0/scope", "Unknown") + if b := (battery{}).Detect(context.Background()); !b.Present || b.Detail != "BAT0" { + t.Fatalf("battery: %+v", b) + } +}