joshuar-go-hass-agent/pkg/linux/hwmon/hwmon.go

532 lines
13 KiB
Go

// Copyright (c) 2024 Joshua Rich <joshua.rich@gmail.com>
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package hwmon
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/iancoleman/strcase"
"golang.org/x/sys/unix"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// HWMonPath is the detault path prefix where the hwmon userspace API exists in
// SysFS. Generally, this will never change, but is exposed as a variable to
// ease with writing tests.
var HWMonPath = "/sys/class/hwmon"
//go:generate stringer -type=MonitorType -output hwmon_MonitorType_generated.go
const (
Unknown MonitorType = iota
Temp
Fan
Voltage
PWM
Current
Power
Energy
Humidity
Frequency
Alarm
Intrusion
)
// MonitorType represents the type of sensor. For example, a temp sensor, a fan
// sensor, etc.
type MonitorType int
// Chip represents a sensor chip exposed by the Linux kernel hardware monitoring
// API. These are retrieved from the directories in the sysfs /sys/devices tree
// under /sys/class/hwmon/hwmon*.
type Chip struct {
chipName string
chipID string
deviceModel string
Path string
Sensors []*Sensor
}
// Chip returns a formatted string for identifying the chip to which this sensor
// belongs.
func (c *Chip) String() string {
if c.deviceModel != "" {
return c.deviceModel
}
if c.chipName != "" {
return c.chipName
}
return c.chipID
}
// getSensors retrieves all the sensors for the chip from hwmon sysfs. It
// returns a slice of the sensors. If it cannot read the hwmon sysfs path, a
// non-nil error is returned with details. It will not return an error if there
// was an error retrieving an individual sensor for the chip.
func (c *Chip) getSensors() ([]*Sensor, error) {
allSensorFiles, err := getSensorFiles(c.Path)
if err != nil {
return []*Sensor{}, fmt.Errorf("could not gather sensor files: %w", err)
}
// generate a map of allSensors from the files.
allSensors := make(map[string]*Sensor)
for file := range allSensorFiles {
trackerID := file.sensorID + "_" + file.sensorType.String()
if _, ok := allSensors[trackerID]; !ok {
allSensors[trackerID] = newSensor(&file, c)
}
// Update based on the file contents
if err := allSensors[trackerID].updateInfo(&file); err != nil {
slog.Debug("Could not update sensor.",
slog.String("sensor", allSensors[trackerID].Name()),
slog.Any("error", err))
}
}
sensors := make([]*Sensor, 0, len(allSensors))
for _, sensor := range allSensors {
if sensor.value == nil {
slog.Debug("Ignoring sensor with nil value.", slog.String("sensor", sensor.Name()))
continue
}
sensors = append(sensors, sensor)
}
return sensors, nil
}
func getSensorFiles(hwMonPath string) (chan sensorFile, error) {
fileList, err := os.ReadDir(hwMonPath)
if err != nil {
return nil, fmt.Errorf("could not read files at path %s: %w", hwMonPath, err)
}
fileCh := make(chan sensorFile)
go func() {
var wg sync.WaitGroup
for _, file := range fileList {
wg.Add(1)
go func() {
defer wg.Done()
// ignore directories
if file.IsDir() {
return
}
// ignore files that can't be parsed as a sensor
id, attr, ok := strings.Cut(file.Name(), "_")
if !ok {
return
}
// adjust id for alarms.
if strings.Contains(attr, "alarm") {
id = id + "_alarm"
}
// get and store the contents of the sensor file.
contents, err := getFileContents(filepath.Join(hwMonPath, file.Name()))
if err != nil {
return
}
// return as a sensorFile.
fileCh <- sensorFile{
path: hwMonPath,
name: file.Name(),
sensorID: id,
attribute: attr,
sensorType: parseSensorType(id),
contents: contents,
}
}()
}
wg.Wait()
close(fileCh)
}()
return fileCh, nil
}
// newChip creates a new chip from the given hwmon sysfs path.
func newChip(path string) (*Chip, error) {
chipName, err := getFileContents(filepath.Join(path, "name"))
if err != nil {
return nil, err
}
chip := &Chip{
chipName: chipName,
chipID: filepath.Base(path),
Path: path,
}
fh, err := os.Stat(filepath.Join(path, "device", "model"))
if err == nil && fh.Mode().IsRegular() {
chip.deviceModel, err = getFileContents(filepath.Join(path, "device", "model"))
if err == nil {
slog.Debug("Could not retrieve a device model for chip.",
slog.String("chip", chip.chipName),
slog.Any("error", err))
}
}
sensors, err := chip.getSensors()
chip.Sensors = sensors
return chip, err
}
// GetAllChips will return a slice of Chips containing their sensors. If there
// are any errors in parsing chip or sensor values, it will return a non-nill
// composite error as well.
func GetAllChips() ([]*Chip, error) {
// Get all the hwmon chips.
files, err := os.ReadDir(HWMonPath)
if err != nil {
return nil, fmt.Errorf("could not read hwmon data at path %s: %w", HWMonPath, err)
}
chips := make([]*Chip, 0, len(files))
chipCh := make(chan *Chip)
// Spawn a goroutine for each chip to get its details and retrieve its sensors.
go func() {
defer close(chipCh)
var wg sync.WaitGroup
for _, file := range files {
wg.Add(1)
go func() {
defer wg.Done()
if chip, err := newChip(filepath.Join(HWMonPath, file.Name())); err != nil {
slog.Debug("Could not process hwmon path.",
slog.String("path", file.Name()),
slog.Any("error", err))
} else {
chipCh <- chip
}
}()
}
wg.Wait()
}()
// Collect all valid chips.
for chip := range chipCh {
chips = append(chips, chip)
}
return chips, nil
}
// Sensor represents a single sensor exposed by a sensor chip. A Sensor may have
// a label, which is a formatted name of the sensor, otherwise it will just have
// a name. The Sensor will also have a value. It may also have zero or more
// Attributes, which are additional measurements like max/min/avg of the value.
type Sensor struct {
*Chip
value any
label string
id string
units string
// Attributes is a slice of additional attributes, such as max, min, crit
// values for the sensor.
Attributes []Attribute
scaleFactor float64
MonitorType MonitorType
}
// Value returns the current value of the sensor.
func (s *Sensor) Value() any {
return s.value
}
// Name returns a formatted string as the name for the sensor. It will be
// derived from the chip name plus either any label, else name of the sensor
// itself.
func (s *Sensor) Name() string {
var name strings.Builder
capitaliser := cases.Title(language.English)
if s.deviceModel != "" {
name.WriteString(s.deviceModel)
} else {
name.WriteString("Hardware Sensor")
if s.chipName != "" {
name.WriteString(" ")
name.WriteString(capitaliser.String(strings.ReplaceAll(s.chipName, "_", " ")))
}
}
name.WriteString(" ")
if s.MonitorType == Alarm || s.MonitorType == Intrusion {
if !strings.Contains(s.id, "_") {
name.WriteString(capitaliser.String(s.id))
name.WriteString(" ")
}
name.WriteString(capitaliser.String(s.label))
} else {
if s.label != "" {
name.WriteString(capitaliser.String(s.label))
} else {
name.WriteString(capitaliser.String(s.id))
}
}
return name.String()
}
// ID returns a formatted string that can be used as a unique identifier for
// this sensor. This will be some combination of the chip and sensor details, as
// appropriate.
func (s *Sensor) ID() string {
var id strings.Builder
id.WriteString(s.chipID)
id.WriteString("_")
id.WriteString(s.chipName)
id.WriteString("_")
id.WriteString(s.id)
return strcase.ToSnake(id.String())
}
// Units returns the units for the value of this sensor.
func (s *Sensor) Units() string {
return s.units
}
// String will format the sensor name and value as a pretty string.
func (s *Sensor) String() string {
var sensorStr strings.Builder
fmt.Fprintf(&sensorStr,
"%s: %v %s [%s] (id: %s, path: %s, chip: %s)",
s.Name(), s.Value(), s.Units(), s.MonitorType, s.ID(), s.Path, s.Chip.String())
for idx, a := range s.Attributes {
if idx == 0 {
fmt.Fprintf(&sensorStr, " (")
}
sensorStr.WriteString(a.String())
if idx < len(s.Attributes)-1 {
fmt.Fprintf(&sensorStr, ", ")
}
if idx == len(s.Attributes)-1 {
fmt.Fprintf(&sensorStr, ")")
}
}
return sensorStr.String()
}
// updateInfo will add any additional info from the given sensorFile to the
// sensor. This function is called in a loop processing files for a chip from
// the hwmon sysfs, and will gradually build all the details of the sensor as
// relevant.
func (s *Sensor) updateInfo(file *sensorFile) error {
switch {
case file.attribute == "label":
s.label = file.contents
case strings.Contains(file.attribute, "alarm"):
id, _, _ := strings.Cut(file.sensorID, "_")
parts := strings.Split(file.attribute, "_")
if len(parts) == 2 { // 2 parts, limit alarm
s.label = strings.Join([]string{id, parts[0], file.sensorType.String()}, " ")
s.id = strings.Join([]string{id, parts[0], "alarm"}, "_")
} else { // channel alarm
s.label = strings.Join([]string{id, file.sensorType.String()}, " ")
}
if value, err := strconv.ParseBool(file.contents); err != nil {
return fmt.Errorf("could not parse as bool: %w", err)
} else {
s.value = value
}
case strings.Contains(file.attribute, "intrusion"):
id, _, _ := strings.Cut(file.sensorID, "_")
s.label = strings.Join([]string{id, file.sensorType.String()}, " ")
if value, err := strconv.ParseBool(file.contents); err != nil {
return fmt.Errorf("could not parse as bool: %w", err)
} else {
s.value = value
}
default: // Either the sensor value or an attribute of the sensor.
value, err := strconv.ParseFloat(file.contents, 64)
if err != nil {
return fmt.Errorf("could not parse as float: %w", err)
}
if file.attribute == "input" {
s.value = value / s.scaleFactor
} else {
s.Attributes = append(s.Attributes, Attribute{Name: file.attribute, Value: value / s.scaleFactor})
}
}
return nil
}
// newSensor creates a new sensor representation from the given sensorFile.
func newSensor(file *sensorFile, chip *Chip) *Sensor {
return &Sensor{
Chip: chip,
id: file.sensorID,
MonitorType: file.sensorType,
scaleFactor: getScaleFactor(file.sensorType),
units: getUnits(file.sensorType),
}
}
// GetAllSensors returns a slice of Sensor objects, representing all detected
// chip sensors found on the host. If there were any errors in fetching chips or
// chip sensors, it will also return a non-nill composite error.
func GetAllSensors() ([]*Sensor, error) {
var sensors []*Sensor
chips, err := GetAllChips()
for _, chip := range chips {
sensors = append(sensors, chip.Sensors...)
}
return sensors, err
}
// Attribute represents an attribute of a sensor, like its max, min or average
// value.
type Attribute struct {
Name string
Value float64
}
// String will format the attribute name and value as a pretty string.
func (a *Attribute) String() string {
return fmt.Sprintf("%s: %.3f", a.Name, a.Value)
}
type sensorFile struct {
path string
name string
sensorID string
attribute string
contents string
sensorType MonitorType
}
func getScaleFactor(sensorType MonitorType) float64 {
switch sensorType {
case Intrusion, Alarm, Fan, PWM, Current, Humidity:
return 1
case Temp, Voltage, Power, Energy, Frequency:
return 1000
default:
return 1
}
}
func getUnits(sensorType MonitorType) string {
switch sensorType {
case Temp:
return "°C"
case Fan:
return "rpm"
case Voltage:
return "V"
case PWM, Frequency:
return "Hz"
case Current:
return "A"
case Power:
return "W"
case Energy:
return "J"
case Humidity:
return "%"
default:
return ""
}
}
func parseSensorType(id string) MonitorType {
switch {
case strings.Contains(id, "intrusion"):
return Intrusion
case strings.Contains(id, "alarm"):
return Alarm
case strings.Contains(id, "temp"):
return Temp
case strings.Contains(id, "fan"):
return Fan
case strings.Contains(id, "in"):
return Voltage
case strings.Contains(id, "pwm"):
return PWM
case strings.Contains(id, "curr"):
return Current
case strings.Contains(id, "power"):
return Power
case strings.Contains(id, "energy"):
return Energy
case strings.Contains(id, "humidity"):
return Humidity
case strings.Contains(id, "freq"):
return Frequency
default:
return Unknown
}
}
// Adapted from:
// https://github.com/prometheus/node_exporter/blob/master/collector/hwmon_linux.go
func getFileContents(file string) (string, error) {
handle, err := os.Open(file)
if err != nil {
return "", fmt.Errorf("could not open file: %w", err)
}
defer handle.Close()
// On some machines, hwmon drivers are broken and return EAGAIN. This causes
// Go's os.ReadFile implementation to poll forever.
//
// Since we either want to read data or bail immediately, do the simplest
// possible read using system call directly.
data := make([]byte, 128)
n, err := unix.Read(int(handle.Fd()), data) // #nosec G115 // I do not believe this is a problem.
if err != nil {
return "", fmt.Errorf("could not read contents of file: %w", err)
}
return strings.TrimSpace(string(data[:n])), nil
}