Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions sysfs/class_power_meter.go
Original file line number Diff line number Diff line change
@@ -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
}
179 changes: 179 additions & 0 deletions sysfs/class_power_meter_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading