From 93c044d027e4aba63231caf0de4c8419b12a24ac Mon Sep 17 00:00:00 2001 From: dongjiang Date: Thu, 13 Aug 2026 21:27:45 +0800 Subject: [PATCH] sysfs: add support for ACPI 4.0 power meters Add GetPowerMeters() to enumerate ACPI power meter devices exposed by the kernel driver drivers/hwmon/acpi_power_meter.c via the ACPI bus path /sys/bus/acpi/drivers/power_meter/ACPI000D:*. The PowerMeter struct covers: - Standard hwmon attributes (power1_average, *_interval, alarm, etc.) - Optional capping attributes (power1_cap, cap_min/max/hyst) - ACPI extension attributes (accuracy, is_battery, model/serial/OEM) - The measures/ subdirectory (list of measured devices) All optional fields use pointer types and are nil when the firmware does not expose them, ensuring graceful degradation across different ACPI implementations. Returns nil, nil when the ACPI bus path does not exist, matching the behaviour of GetRaplZones. Closes #753 Signed-off-by: dongjiang Signed-off-by: dongjiang --- sysfs/class_power_meter.go | 198 ++++++++++++++++++++++++++++++++ sysfs/class_power_meter_test.go | 179 +++++++++++++++++++++++++++++ testdata/fixtures.ttar | 122 ++++++++++++++++++++ 3 files changed, 499 insertions(+) create mode 100644 sysfs/class_power_meter.go create mode 100644 sysfs/class_power_meter_test.go diff --git a/sysfs/class_power_meter.go b/sysfs/class_power_meter.go new file mode 100644 index 00000000..43cf3f5f --- /dev/null +++ b/sysfs/class_power_meter.go @@ -0,0 +1,198 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package sysfs + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/prometheus/procfs/internal/util" +) + +// PowerMeter represents a single ACPI 4.0 power meter device, as exposed by +// the kernel driver drivers/hwmon/acpi_power_meter.c. +// +// All optional fields use pointer types and are nil when the firmware does +// not expose the corresponding sysfs attribute. Fields documented as RW in +// the kernel (AverageMin, AverageMax, AverageInterval, Cap) can be read but +// not written through this API; callers needing to set values must write to +// sysfs directly. +// +// Typical sysfs path: /sys/bus/acpi/drivers/power_meter/ACPI000D:XX/. +type PowerMeter struct { + // Standard hwmon attributes (always exposed by the driver). + Average *int64 // power1_average (microWatt) + AverageMin *int64 // power1_average_min (microWatt, RW) + AverageMax *int64 // power1_average_max (microWatt, RW) + AverageInterval *int64 // power1_average_interval (millisecond, RW) + AverageIntervalMin *int64 // power1_average_interval_min (millisecond) + AverageIntervalMax *int64 // power1_average_interval_max (millisecond) + Alarm *int64 // power1_alarm (0 or 1) + + // Optional capping attributes (present only when the platform supports + // power capping; on non-IBM systems the kernel module must be loaded + // with force_cap_on=1 on kernel >= 4.14). + Cap *int64 // power1_cap (microWatt, RW) + CapMin *int64 // power1_cap_min (microWatt) + CapMax *int64 // power1_cap_max (microWatt) + CapHyst *int64 // power1_cap_hyst (microWatt) + + // ACPI extension attributes (firmware-dependent; may not be present on + // all platforms). + Accuracy *int64 // power1_accuracy (percent) + IsBattery *int64 // power1_is_battery (0 or 1) + ModelNumber string // power1_model_number + SerialNumber string // power1_serial_number + OEMInfo string // power1_oem_info + + // Metadata. + Name string // device directory name (e.g. "ACPI000D:00") + Path string // full sysfs path + Measures []string // device names from the measures/ subdirectory +} + +// PowerMeterClass is the collection of all ACPI power meters enumerated from +// /sys/bus/acpi/drivers/power_meter/. +type PowerMeterClass []PowerMeter + +// GetPowerMeters returns a slice of PowerMeter, one for each ACPI 4.0 power +// meter discovered via /sys/bus/acpi/drivers/power_meter/. Returns nil, nil +// when the ACPI power meter bus path does not exist (i.e. the host has no +// ACPI power meter device). +func GetPowerMeters(fs FS) (PowerMeterClass, error) { + pattern := fs.sys.Path("bus/acpi/drivers/power_meter/ACPI000D:*") + + dirs, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("failed to glob power meter devices: %w", err) + } + if len(dirs) == 0 { + return nil, nil + } + + meters := make(PowerMeterClass, 0, len(dirs)) + for _, d := range dirs { + pm, err := parsePowerMeter(d) + if err != nil { + return nil, fmt.Errorf("failed to parse power meter %q: %w", d, err) + } + pm.Name = filepath.Base(d) + pm.Path = d + meters = append(meters, *pm) + } + return meters, nil +} + +// parsePowerMeter reads every attribute file inside a single power meter +// sysfs directory and returns a populated PowerMeter. +func parsePowerMeter(path string) (*PowerMeter, error) { + files, err := os.ReadDir(path) + if err != nil { + return nil, err + } + + var pm PowerMeter + for _, f := range files { + // Skip subdirectories (measures/ is handled separately) and + // non-regular files. + if !f.Type().IsRegular() { + continue + } + + name := filepath.Join(path, f.Name()) + value, err := util.SysReadFile(name) + if err != nil { + // Tolerate: device not ready / attribute unsupported / + // permission denied. Matches the strategy used by + // parsePowerSupply in class_power_supply.go. + if os.IsNotExist(err) || + err.Error() == "operation not supported" || + err.Error() == "no such device" || + errors.Is(err, os.ErrInvalid) { + continue + } + return nil, fmt.Errorf("failed to read file %q: %w", name, err) + } + + vp := util.NewValueParser(value) + + switch f.Name() { + case "power1_average": + pm.Average = vp.PInt64() + case "power1_average_min": + pm.AverageMin = vp.PInt64() + case "power1_average_max": + pm.AverageMax = vp.PInt64() + case "power1_average_interval": + pm.AverageInterval = vp.PInt64() + case "power1_average_interval_min": + pm.AverageIntervalMin = vp.PInt64() + case "power1_average_interval_max": + pm.AverageIntervalMax = vp.PInt64() + case "power1_alarm": + pm.Alarm = vp.PInt64() + case "power1_cap": + pm.Cap = vp.PInt64() + case "power1_cap_min": + pm.CapMin = vp.PInt64() + case "power1_cap_max": + pm.CapMax = vp.PInt64() + case "power1_cap_hyst": + pm.CapHyst = vp.PInt64() + case "power1_accuracy": + pm.Accuracy = vp.PInt64() + case "power1_is_battery": + pm.IsBattery = vp.PInt64() + case "power1_model_number": + pm.ModelNumber = value + case "power1_serial_number": + pm.SerialNumber = value + case "power1_oem_info": + pm.OEMInfo = value + } + + if err := vp.Err(); err != nil { + return nil, fmt.Errorf("failed to parse %q: %w", f.Name(), err) + } + } + + // measures/ failure is non-fatal; the directory may be empty or absent. + pm.Measures, _ = parsePowerMeterMeasures(path) + return &pm, nil +} + +// parsePowerMeterMeasures reads the measures/ subdirectory of a power meter +// and returns the basenames of every symlink target (i.e. the device names +// this meter measures). +func parsePowerMeterMeasures(meterPath string) ([]string, error) { + measureDir := filepath.Join(meterPath, "measures") + entries, err := os.ReadDir(measureDir) + if err != nil { + return nil, err + } + + var devices []string + for _, e := range entries { + target, err := os.Readlink(filepath.Join(measureDir, e.Name())) + if err != nil { + continue + } + devices = append(devices, filepath.Base(target)) + } + return devices, nil +} diff --git a/sysfs/class_power_meter_test.go b/sysfs/class_power_meter_test.go new file mode 100644 index 00000000..e43c7c63 --- /dev/null +++ b/sysfs/class_power_meter_test.go @@ -0,0 +1,179 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package sysfs + +import ( + "os" + "path/filepath" + "sort" + "testing" +) + +func TestGetPowerMeters(t *testing.T) { + fs, err := NewFS(sysTestFixtures) + if err != nil { + t.Fatalf("failed to open filesystem: %v", err) + } + + meters, err := GetPowerMeters(fs) + if err != nil { + t.Fatalf("failed to get power meters: %v", err) + } + if len(meters) != 2 { + t.Fatalf("expected 2 power meters, got %d", len(meters)) + } + + // Sort by name for deterministic ordering (glob order varies by OS). + sort.Slice(meters, func(i, j int) bool { return meters[i].Name < meters[j].Name }) + + // --- ACPI000D:00: full-field meter --- + m0 := meters[0] + if m0.Name != "ACPI000D:00" { + t.Fatalf("expected Name=ACPI000D:00, got %q", m0.Name) + } + + assertPInt64(t, "ACPI000D:00.Average", m0.Average, 15000000) + assertPInt64(t, "ACPI000D:00.AverageMin", m0.AverageMin, 0) + assertPInt64(t, "ACPI000D:00.AverageMax", m0.AverageMax, 60000000) + assertPInt64(t, "ACPI000D:00.AverageInterval", m0.AverageInterval, 1000) + assertPInt64(t, "ACPI000D:00.AverageIntervalMin", m0.AverageIntervalMin, 100) + assertPInt64(t, "ACPI000D:00.AverageIntervalMax", m0.AverageIntervalMax, 10000) + assertPInt64(t, "ACPI000D:00.Alarm", m0.Alarm, 0) + assertPInt64(t, "ACPI000D:00.Cap", m0.Cap, 25000000) + assertPInt64(t, "ACPI000D:00.CapMin", m0.CapMin, 1000000) + assertPInt64(t, "ACPI000D:00.CapMax", m0.CapMax, 100000000) + assertPInt64(t, "ACPI000D:00.CapHyst", m0.CapHyst, 500000) + assertPInt64(t, "ACPI000D:00.Accuracy", m0.Accuracy, 95) + assertPInt64(t, "ACPI000D:00.IsBattery", m0.IsBattery, 0) + + if m0.ModelNumber != "ACME PM01" { + t.Errorf("expected ModelNumber=%q, got %q", "ACME PM01", m0.ModelNumber) + } + if m0.SerialNumber != "SN12345" { + t.Errorf("expected SerialNumber=%q, got %q", "SN12345", m0.SerialNumber) + } + if m0.OEMInfo != "ACME Corp" { + t.Errorf("expected OEMInfo=%q, got %q", "ACME Corp", m0.OEMInfo) + } + + sort.Strings(m0.Measures) + if len(m0.Measures) != 2 || m0.Measures[0] != "LNXCPU:00" || m0.Measures[1] != "LNXMEM:00" { + t.Errorf("expected Measures=[LNXCPU:00, LNXMEM:00], got %v", m0.Measures) + } + + // --- ACPI000D:01: minimal-field meter (verify optional fields are nil) --- + m1 := meters[1] + if m1.Name != "ACPI000D:01" { + t.Fatalf("expected Name=ACPI000D:01, got %q", m1.Name) + } + + assertPInt64(t, "ACPI000D:01.Average", m1.Average, 5000000) + assertPInt64(t, "ACPI000D:01.AverageInterval", m1.AverageInterval, 500) + assertPInt64(t, "ACPI000D:01.Alarm", m1.Alarm, 0) + + assertNilPInt64(t, "ACPI000D:01.AverageMin", m1.AverageMin) + assertNilPInt64(t, "ACPI000D:01.AverageMax", m1.AverageMax) + assertNilPInt64(t, "ACPI000D:01.AverageIntervalMin", m1.AverageIntervalMin) + assertNilPInt64(t, "ACPI000D:01.AverageIntervalMax", m1.AverageIntervalMax) + assertNilPInt64(t, "ACPI000D:01.Cap", m1.Cap) + assertNilPInt64(t, "ACPI000D:01.CapMin", m1.CapMin) + assertNilPInt64(t, "ACPI000D:01.CapMax", m1.CapMax) + assertNilPInt64(t, "ACPI000D:01.CapHyst", m1.CapHyst) + assertNilPInt64(t, "ACPI000D:01.Accuracy", m1.Accuracy) + assertNilPInt64(t, "ACPI000D:01.IsBattery", m1.IsBattery) + + if m1.ModelNumber != "" { + t.Errorf("expected ModelNumber=\"\", got %q", m1.ModelNumber) + } + if m1.SerialNumber != "" { + t.Errorf("expected SerialNumber=\"\", got %q", m1.SerialNumber) + } + if m1.OEMInfo != "" { + t.Errorf("expected OEMInfo=\"\", got %q", m1.OEMInfo) + } + if len(m1.Measures) != 0 { + t.Errorf("expected empty Measures for ACPI000D:01, got %v", m1.Measures) + } +} + +func TestGetPowerMeters_NoDevices(t *testing.T) { + fs, err := NewFS(t.TempDir()) + if err != nil { + t.Fatalf("failed to open filesystem: %v", err) + } + + meters, err := GetPowerMeters(fs) + if err != nil { + t.Fatalf("expected no error when no devices exist, got %v", err) + } + if meters != nil { + t.Fatalf("expected nil meters, got %v", meters) + } +} + +func TestGetPowerMeters_SingleMeter(t *testing.T) { + // Verify parsing works when only a subset of attributes is present. + tmp := t.TempDir() + meterDir := filepath.Join(tmp, "bus", "acpi", "drivers", "power_meter", "ACPI000D:00") + if err := os.MkdirAll(filepath.Join(meterDir, "measures"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(meterDir, "power1_average"), []byte("1000"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(meterDir, "power1_alarm"), []byte("1"), 0o644); err != nil { + t.Fatal(err) + } + + fs, err := NewFS(tmp) + if err != nil { + t.Fatal(err) + } + + meters, err := GetPowerMeters(fs) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(meters) != 1 { + t.Fatalf("expected 1 meter, got %d", len(meters)) + } + if meters[0].Name != "ACPI000D:00" { + t.Errorf("expected Name=ACPI000D:00, got %q", meters[0].Name) + } + assertPInt64(t, "Average", meters[0].Average, 1000) + assertPInt64(t, "Alarm", meters[0].Alarm, 1) + assertNilPInt64(t, "Cap", meters[0].Cap) +} + +// assertPInt64 checks that a *int64 field is non-nil and holds the expected value. +func assertPInt64(t *testing.T, field string, got *int64, want int64) { + t.Helper() + if got == nil { + t.Errorf("%s: expected %d, got nil", field, want) + return + } + if *got != want { + t.Errorf("%s: expected %d, got %d", field, want, *got) + } +} + +// assertNilPInt64 checks that a *int64 field is nil. +func assertNilPInt64(t *testing.T, field string, got *int64) { + t.Helper() + if got != nil { + t.Errorf("%s: expected nil, got %d", field, *got) + } +} diff --git a/testdata/fixtures.ttar b/testdata/fixtures.ttar index 1f6c325e..9eae60b0 100644 --- a/testdata/fixtures.ttar +++ b/testdata/fixtures.ttar @@ -4993,6 +4993,128 @@ Mode: 644 Directory: fixtures/sys/bus Mode: 755 # ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Directory: fixtures/sys/bus/acpi +Mode: 755 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Directory: fixtures/sys/bus/acpi/drivers +Mode: 755 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Directory: fixtures/sys/bus/acpi/drivers/power_meter +Mode: 755 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Directory: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00 +Mode: 755 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Directory: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/measures +Mode: 755 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/measures/cpu0 +SymlinkTo: ../../../../devices/LNXSYSTM:00/LNXCPU:00 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/measures/mem0 +SymlinkTo: ../../../../devices/LNXSYSTM:00/LNXMEM:00 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_accuracy +Lines: 1 +95 +Mode: 444 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_alarm +Lines: 1 +0 +Mode: 444 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_average +Lines: 1 +15000000 +Mode: 444 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_average_interval +Lines: 1 +1000 +Mode: 644 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_average_interval_max +Lines: 1 +10000 +Mode: 444 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_average_interval_min +Lines: 1 +100 +Mode: 444 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_average_max +Lines: 1 +60000000 +Mode: 644 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_average_min +Lines: 1 +0 +Mode: 644 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_cap +Lines: 1 +25000000 +Mode: 644 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_cap_hyst +Lines: 1 +500000 +Mode: 444 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_cap_max +Lines: 1 +100000000 +Mode: 444 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_cap_min +Lines: 1 +1000000 +Mode: 444 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_is_battery +Lines: 1 +0 +Mode: 444 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_model_number +Lines: 1 +ACME PM01 +Mode: 444 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_oem_info +Lines: 1 +ACME Corp +Mode: 444 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:00/power1_serial_number +Lines: 1 +SN12345 +Mode: 444 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Directory: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:01 +Mode: 755 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Directory: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:01/measures +Mode: 755 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:01/power1_alarm +Lines: 1 +0 +Mode: 444 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:01/power1_average +Lines: 1 +5000000 +Mode: 444 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +Path: fixtures/sys/bus/acpi/drivers/power_meter/ACPI000D:01/power1_average_interval +Lines: 1 +500 +Mode: 644 +# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Directory: fixtures/sys/bus/pci Mode: 755 # ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -