package agent import ( "log/slog" "os" "path/filepath" "strings" "sync" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" ) type fanSensor struct { key, path string } var getFanSensors = newFanSensorCache(hwmonRoot) func newFanSensorCache(root string) func() ([]fanSensor, error) { return sync.OnceValues(func() ([]fanSensor, error) { return discoverHwmonFans(root) }) } // updateFans populates systemStats.Fans from the host's hwmon sysfs tree. // No-op on platforms where hwmon isn't available (see fans_other.go). func (a *Agent) updateFans(systemStats *system.Stats) { if hwmonRoot == "" { return } sensors, err := getFanSensors() if err != nil { slog.Debug("Error reading fans", "err", err) return } fans := readFanSensors(sensors) if len(fans) == 0 { return } systemStats.Fans = fans // Note: Commented out because we don't currently use this value in the UI. // Compute the single "dashboard" value used by the FanSpeed alert. // Per-sensor RPMs live in Stats.Fans and drive the multi-line FanChart // in the UI; the alert path only needs one number to compare against // the user's threshold, so we use the highest RPM across all fans // a.systemInfo.DashboardFan = 0 // for _, rpm := range fans { // if rpm > a.systemInfo.DashboardFan { // a.systemInfo.DashboardFan = rpm // } // } } // readHwmonFans walks the given hwmon root (typically /sys/class/hwmon) and // returns a map of "_" → RPM for every fan*_input // file it finds. Zero RPM is retained because it can represent a real fan that // has stopped; negative and malformed readings are ignored. func readHwmonFans(root string) (map[string]uint16, error) { sensors, err := discoverHwmonFans(root) if err != nil { return nil, err } return readFanSensors(sensors), nil } func discoverHwmonFans(root string) ([]fanSensor, error) { entries, err := os.ReadDir(root) if err != nil { return nil, err } var sensors []fanSensor for _, entry := range entries { chipDir := filepath.Join(root, entry.Name()) chipName := utils.ReadStringFile(filepath.Join(chipDir, "name")) if chipName == "" { chipName = entry.Name() } inputs, _ := filepath.Glob(filepath.Join(chipDir, "fan*_input")) for _, inputPath := range inputs { base := strings.TrimSuffix(filepath.Base(inputPath), "_input") label := utils.ReadStringFile(filepath.Join(chipDir, base+"_label")) key := chipName + "_" + base if label != "" { key = chipName + "_" + label } sensors = append(sensors, fanSensor{key, inputPath}) } } return sensors, nil } func readFanSensors(sensors []fanSensor) map[string]uint16 { fans := make(map[string]uint16, len(sensors)) for _, sensor := range sensors { if rpm, ok := utils.ReadUintFile(sensor.path); ok { fans[sensor.key] = uint16(rpm) } } return fans }