Files
mesh-catalog/modules/sensors/cmd/sensors/fans.go
T
jochen 66a816c3ac
mesh/merge-gate pass: builds new: modules/sensors, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
sensors: say a stuck drive read, and be the drives' only reader
The second review of #129 found a read stuck in the kernel kept the file fresh and the module healthy,
smartd as a second reader could wake or keep awake the drives the watch spares, and a board chip's
maximum could stop a fan. Read each drive in its own goroutine from a root reader of the module's own,
mark each read in flight, and say a read past its bound or a drive not looked at for twice its cadence;
drop smartd; tell smartctl each drive's kind; take a SAS drive's standby; say a drive asleep since when.
2026-10-08 14:59:10 +02:00

133 lines
4.6 KiB
Go

package main
import (
"fmt"
"path"
"strings"
)
// Fans, from the hwmon chips. Many fans stop on purpose when the machine is cool (a laptop at rest, a GPU
// in its zero-fan mode), so 0 RPM alone is not a fault; nor is a chip's minimum or alarm bit, which a
// zero-fan GPU is under by design and a Super I/O chip latches. Those are said in a note and raise nothing.
// A fan is STOPPED only when it ran since the machine started, reads 0 now, and a temperature that fan
// cools is at or above the hardware's own maximum: a CPU sensor's, a thermal zone's, or one on the fan's
// own chip unless that is a board chip (novox/hq ADR 0256 rule 4).
// FlagStopped is said beside a fan that should be turning and is not.
const FlagStopped = "STOPPED"
// Fan is one fan's reading.
type Fan struct {
Chip string `json:"chip"`
Label string `json:"label"`
// Key names the fan across readings: its chip and its input (asus/fan1).
Key string `json:"key"`
RPM *int64 `json:"rpm,omitempty"`
// Min is the chip's own lowest speed for it, where it gives one.
Min *int64 `json:"min_rpm,omitempty"`
Alarm bool `json:"alarm,omitempty"`
// RanSinceBoot says the module's watch saw it turning since the machine started; absent when the
// watch has not said.
RanSinceBoot *bool `json:"ran_since_boot,omitempty"`
Flag string `json:"flag,omitempty"`
Why string `json:"why,omitempty"`
// Note is what the chip says that raises nothing: under its own minimum, its alarm bit.
Note string `json:"note,omitempty"`
NotRead string `json:"not_read,omitempty"`
Where string `json:"where"`
}
// Name is how a person reads which fan this is.
func (f Fan) Name() string {
if f.Label != "" && !strings.HasPrefix(f.Label, "fan") {
return fmt.Sprintf("the fan %s (%s)", f.Label, f.Chip)
}
return fmt.Sprintf("the fan %s of %s", f.Label, f.Chip)
}
// Fans is every fan the machine reports, read now.
func (m *Machine) Fans() []Fan {
var out []Fan
for _, h := range m.numbered("/sys/class/hwmon", "hwmon") {
dir := "/sys/class/hwmon/" + h
chip := m.read(dir + "/name")
for _, stem := range m.inputs(dir, "fan") {
f := Fan{Chip: chip, Label: m.read(dir + "/" + stem + "_label"), Key: chip + "/" + stem, Where: dir + "/" + stem}
if f.Label == "" {
f.Label = stem
}
v, ok := m.readInt(dir + "/" + stem + "_input")
if !ok || v < 0 {
f.NotRead = "the fan gave no speed"
out = append(out, f)
continue
}
f.RPM = &v
if mn, ok := m.readInt(dir + "/" + stem + "_min"); ok && mn > 0 {
f.Min = &mn
}
if a, ok := m.readInt(dir + "/" + stem + "_alarm"); ok && a != 0 {
f.Alarm = true
}
out = append(out, f)
}
}
return out
}
// judgeFans flags each fan, given which fans the watch saw turning since the machine started (nil when it
// has not said) and the temperatures read now.
func judgeFans(fans []Fan, ran map[string]bool, temps []Temperature) []Fan {
for i := range fans {
f := &fans[i]
if ran != nil {
r := ran[f.Key]
f.RanSinceBoot = &r
}
if f.RPM == nil {
continue
}
var notes []string
if f.Alarm {
notes = append(notes, fmt.Sprintf("its chip's alarm bit is set at %d RPM", *f.RPM))
}
if f.Min != nil && *f.RPM < *f.Min {
notes = append(notes, fmt.Sprintf("it turns at %d RPM, under its chip's minimum of %d RPM", *f.RPM, *f.Min))
}
f.Note = strings.Join(notes, "; ")
if *f.RPM != 0 || f.RanSinceBoot == nil || !*f.RanSinceBoot {
continue
}
if hot := hottestFor(*f, temps); hot != nil {
f.Flag, f.Why = FlagStopped, fmt.Sprintf("it ran earlier and reads 0 RPM while %s is at %.1f °C, at or above %s of %.1f °C",
hot.Name(), *hot.C, hot.HighIs, *hot.High)
}
}
return fans
}
// hottestFor is the temperature furthest at or above its own high value among those this fan may be
// cooling: a CPU sensor, a thermal zone (whose trips the firmware sets for the machine as a whole), or a
// sensor on the fan's own chip when that chip is not a board chip. A board chip's (a Super I/O's) limits
// are whatever the firmware left, so its maximum is no more trusted here than its critical value is for
// health. A drive or a network adapter running hot says nothing of a fan.
func hottestFor(f Fan, ts []Temperature) *Temperature {
chip := path.Dir(f.Where)
var best *Temperature
for i := range ts {
t := &ts[i]
if t.C == nil || t.High == nil || *t.C < *t.High {
continue
}
related := t.Kind == "cpu" || t.Source == "thermal zone" ||
(t.Source == "hwmon" && path.Dir(t.Where) == chip && kindOf(f.Chip) != "board")
if !related {
continue
}
if best == nil || *t.C-*t.High > *best.C-*best.High {
best = t
}
}
return best
}