From cc9155ca17adbbe1789c6fb53ab9ba8bc252f1a7 Mon Sep 17 00:00:00 2001 From: dongjiang Date: Fri, 28 Aug 2026 21:11: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 an empty slice with os.ErrNotExist when the ACPI bus path does not exist. Note: power1_accuracy is preserved as a string because the kernel emits it as a formatted percent value (e.g. "1.50%"). Closes #753 Signed-off-by: dongjiang --- sysfs/class_power_meter.go | 204 ++++++++++++++++++++++++++++++++ sysfs/class_power_meter_test.go | 183 ++++++++++++++++++++++++++++ testdata/fixtures.ttar | 122 +++++++++++++++++++ 3 files changed, 509 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..6bf8a2bb --- /dev/null +++ b/sysfs/class_power_meter.go @@ -0,0 +1,204 @@ +// 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/parsers" +) + +// 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/. +// +// See: https://docs.kernel.org/hwmon/acpi_power_meter.html +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). + // + // Note: the kernel emits power1_accuracy as a string with a percent + // suffix (e.g. "1.50%"), so it is preserved as-is rather than parsed + // to a number. + Accuracy string // power1_accuracy (e.g. "1.50%") + 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 PowerMeterClass{}, os.ErrNotExist + } + + 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 := parsers.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 := parsers.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 = value + 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..e6ecd5b6 --- /dev/null +++ b/sysfs/class_power_meter_test.go @@ -0,0 +1,183 @@ +// 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.IsBattery", m0.IsBattery, 0) + + if m0.Accuracy != "1.50%" { + t.Errorf("expected Accuracy=%q, got %q", "1.50%", m0.Accuracy) + } + 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.IsBattery", m1.IsBattery) + + if m1.Accuracy != "" { + t.Errorf("expected Accuracy=\"\" for ACPI000D:01, got %q", m1.Accuracy) + } + 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 !os.IsNotExist(err) { + t.Fatalf("expected os.ErrNotExist when no devices exist, got %v", err) + } + if len(meters) != 0 { + t.Fatalf("expected empty 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 1fa6faf5..c6dd8b96 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 +1.50% +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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -