Merge pull request 'Report whether a battery powers the machine and whether anything measures its draw (hq ADR 0255)' (#56) from feat/the-bar-takes-blocks into main
This commit was merged in pull request #56.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user