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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -