The fan-ceiling check, the webui metrics collector (every 5s) and the PSU health poller each shelled out to ipmitool independently. The BMC's KCS interface serializes those calls anyway, so under load the concurrent `ipmitool sdr`/`dcmi power reading` invocations just queued behind each other — that's what produced "IPMI slow" backoff during a fan-ceiling run in a blackbox dump, while the dashboard looked fine only because it was reading its own, separately-stale data from a different ipmitool call. hw_telemetry.go is now the sole recurring poller (fan RPM, temperature, PSU power/status, DCMI system power), with the adaptive 1s-30s backoff that used to be duplicated inside the fan check. Every hot-path consumer reads the shared cache (hwSnapshot / platform.HardwareSDRSnapshot) instead of calling ipmitool itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
969 lines
28 KiB
Go
969 lines
28 KiB
Go
package platform
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// FanCheckOptions configures the fan-ceiling check: it drives CPU (+memory)
|
|
// and, when present, GPU load to 100% simultaneously, then watches every fan
|
|
// until none has climbed for PlateauHoldSec — at which point each fan is
|
|
// considered to be at its physical ceiling and the observed peak is recorded.
|
|
type FanCheckOptions struct {
|
|
PlateauHoldSec int // a fan must not rise > PlateauDeltaRPM for this long to count as plateaued (default 60)
|
|
PlateauDeltaRPM int // RPM increase that still counts as "climbing" (default 50)
|
|
MinLoadSec int // never declare a plateau before this many seconds of load (default 90)
|
|
MaxLoadSec int // hard cap on the load phase; finish (success) even if not every fan plateaued (default 900)
|
|
RampConfirmRPM int // at least one fan must exceed baseline by this before a plateau is "real" (default 150)
|
|
SizeMB int // GPU memory to allocate per GPU (0 = auto)
|
|
GPUIndices []int // which GPU indices to load (empty = all detected)
|
|
}
|
|
|
|
// FanReading holds one fan sensor reading.
|
|
type FanReading struct {
|
|
Name string `json:"name"`
|
|
RPM float64 `json:"rpm"`
|
|
}
|
|
|
|
type cachedPowerReading struct {
|
|
Value float64
|
|
Source string
|
|
Mode string
|
|
Reason string
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
const systemPowerHoldTTL = 15 * time.Second
|
|
|
|
// fanPeaks is the observed top-RPM store — the "autotune" for fan ceilings.
|
|
// Any full-load run (Fan Ceiling Check, burn, thermal cycling) feeds it via
|
|
// updateFanObservation; ResolveFanMaxRPM / ObservedFanMaxRPM read it back.
|
|
var fanPeaks = &observedPeakStore{
|
|
path: "/var/log/bee-sat/fan-observation.json",
|
|
jsonKey: "max_rpm",
|
|
roundUp: 1000,
|
|
minHold: time.Second,
|
|
}
|
|
|
|
// RunFanCheck drives CPU (+memory) and, when a GPU is present, GPU load to
|
|
// 100% simultaneously and watches every fan until none has climbed for
|
|
// PlateauHoldSec. At that point each fan is taken to be at its physical
|
|
// ceiling; the observed peak RPM is persisted (fanObservationStatePath, the
|
|
// same store ObservedFanMaxRPM reads) so the topology view can size each fan
|
|
// tile against a real maximum.
|
|
//
|
|
// Outcome:
|
|
// - success ("ceiling found") once every fan plateaus, or when MaxLoadSec is
|
|
// hit — a run that simply ran out of time still recorded the highest RPM
|
|
// seen and is not a failure.
|
|
// - a fan reading 0 RPM, or an IPMI status of cr/nr, while under full load is
|
|
// a real defect → FAILED.
|
|
// - if there is no way to load this box (no stressapptest/stress-ng and no
|
|
// GPU burn tool) or no fan sensors are readable, the test cannot say
|
|
// anything about the hardware and returns ErrTestNotApplicable so the task
|
|
// is cancelled, not failed.
|
|
//
|
|
// No GPU is not an error: CPU/memory load alone is enough to exercise the
|
|
// cooling loop on most platforms.
|
|
func (s *System) RunFanCheck(ctx context.Context, baseDir string, opts FanCheckOptions, logFunc func(string)) (string, error) {
|
|
if logFunc == nil {
|
|
logFunc = func(string) {}
|
|
}
|
|
if baseDir == "" {
|
|
baseDir = "/var/log/bee-sat"
|
|
}
|
|
applyFanCheckDefaults(&opts)
|
|
|
|
baseFans, fanErr := sampleFanSpeeds()
|
|
if len(baseFans) == 0 {
|
|
return "", fmt.Errorf("no fan sensors readable via ipmitool or lm-sensors (%v): %w", fanErr, ErrTestNotApplicable)
|
|
}
|
|
baselineRPM := make(map[string]float64, len(baseFans))
|
|
for _, f := range baseFans {
|
|
baselineRPM[f.Name] = f.RPM
|
|
}
|
|
|
|
vendor := s.DetectGPUVendor()
|
|
haveGPU := vendor == "nvidia" || vendor == "amd"
|
|
_, cpuPathErr := satLookPath("stressapptest")
|
|
if cpuPathErr != nil {
|
|
_, cpuPathErr = satLookPath("stress-ng")
|
|
}
|
|
haveCPU := cpuPathErr == nil
|
|
if !haveCPU && !haveGPU {
|
|
return "", fmt.Errorf("no load source: stressapptest/stress-ng missing and no NVIDIA/AMD GPU stress tool available: %w", ErrTestNotApplicable)
|
|
}
|
|
|
|
ts := time.Now().UTC().Format("20060102-150405")
|
|
runDir := filepath.Join(baseDir, "fan-check-"+ts)
|
|
if err := os.MkdirAll(runDir, 0755); err != nil {
|
|
return "", err
|
|
}
|
|
verboseLog := filepath.Join(runDir, "verbose.log")
|
|
appendSATVerboseLog(verboseLog, fmt.Sprintf("[%s] fan check start: %d fans, gpu=%s cpu=%v",
|
|
time.Now().UTC().Format(time.RFC3339), len(baseFans), vendor, haveCPU))
|
|
logFunc(fmt.Sprintf("Fan check: %d fans; load = CPU/mem:%v + GPU:%s", len(baseFans), haveCPU, orNone(haveGPU, vendor)))
|
|
|
|
// ── Load: every source runs at the same time, each in its own goroutine.
|
|
// GPU load is the hottest sustained NVIDIA load we have — dcgmproftester
|
|
// -t 1004 / targeted_power, the same engine the Power/Thermal Fit
|
|
// benchmark uses (resolveBenchmarkPowerLoadCommand) — not the
|
|
// compute-throughput bee-gpu-burn, which tops out well below TDP and so
|
|
// never demands the fans' true ceiling.
|
|
//
|
|
// Sources reach full load at different times (stressapptest is instant; a
|
|
// dcgmproftester kernel compiles and ramps), so the plateau clock does not
|
|
// start until every launched source reports its process running, plus a
|
|
// fixed GPU ramp grace.
|
|
loadCtx, loadCancel := context.WithTimeout(ctx, time.Duration(opts.MaxLoadSec)*time.Second)
|
|
defer loadCancel()
|
|
|
|
var loadWG sync.WaitGroup
|
|
started := make(chan bool, 2) // true = source is running, false = failed to launch
|
|
launched := 0
|
|
|
|
if haveCPU {
|
|
launched++
|
|
loadWG.Add(1)
|
|
go func() {
|
|
defer loadWG.Done()
|
|
cmd, err := buildCPUStressCmd(loadCtx)
|
|
if err != nil {
|
|
logFunc("CPU/memory load failed to start: " + err.Error())
|
|
appendSATVerboseLog(verboseLog, "cpu load start error: "+err.Error())
|
|
started <- false
|
|
return
|
|
}
|
|
logFunc("CPU/memory load running (stressapptest)")
|
|
started <- true
|
|
_ = cmd.Wait()
|
|
}()
|
|
}
|
|
if haveGPU {
|
|
launched++
|
|
loadWG.Add(1)
|
|
go func() {
|
|
defer loadWG.Done()
|
|
cmd, label, err := buildFanCheckGPULoadCmd(loadCtx, vendor, opts.MaxLoadSec, opts.GPUIndices)
|
|
if err != nil || cmd == nil {
|
|
logFunc("GPU load unavailable: " + errString(err))
|
|
appendSATVerboseLog(verboseLog, "gpu load unavailable: "+errString(err))
|
|
started <- false
|
|
return
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
logFunc("GPU load failed to start: " + err.Error())
|
|
appendSATVerboseLog(verboseLog, "gpu load start error: "+err.Error())
|
|
started <- false
|
|
return
|
|
}
|
|
logFunc("GPU load running (" + label + ")")
|
|
started <- true
|
|
_ = cmd.Wait()
|
|
}()
|
|
}
|
|
|
|
start := time.Now()
|
|
activeLoads := 0
|
|
for i := 0; i < launched; i++ {
|
|
select {
|
|
case ok := <-started:
|
|
if ok {
|
|
activeLoads++
|
|
}
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
if activeLoads == 0 {
|
|
loadCancel()
|
|
loadWG.Wait()
|
|
return "", fmt.Errorf("every load source failed to start: %w", ErrTestNotApplicable)
|
|
}
|
|
readyAt := time.Now()
|
|
if haveGPU {
|
|
readyAt = readyAt.Add(20 * time.Second) // GPU kernel ramp grace
|
|
}
|
|
appendSATVerboseLog(verboseLog, fmt.Sprintf("%d load source(s) active; plateau clock effective from +%.0fs",
|
|
activeLoads, readyAt.Sub(start).Seconds()))
|
|
|
|
// ── Sample loop, reading the shared hardware telemetry cache.
|
|
//
|
|
// Fan RPM, PSU power and IPMI-hang protection all live in the shared
|
|
// hwTelemetry poller (hw_telemetry.go) now: it is the only thing that
|
|
// ever shells out to ipmitool on a recurring cadence, backs its own
|
|
// polling interval off geometrically when reads are slow (or wedge
|
|
// outright) and tightens again when they recover. This loop just ticks
|
|
// at the cache's floor interval and picks up whatever the shared poller
|
|
// last saw — it never spawns an ipmitool call of its own, so it can't
|
|
// add to the exact contention that made ipmitool slow in the first
|
|
// place. A plateau is only declared while telemetry is healthy (shared
|
|
// poller interval near the floor); a degraded run just rides out to
|
|
// MaxLoadSec and records the peak it saw.
|
|
type fanState struct {
|
|
peak float64
|
|
lastRiseSec float64
|
|
}
|
|
fanBy := map[string]*fanState{}
|
|
rampConfirmed := false
|
|
plateauReached := false
|
|
aborted := false
|
|
degraded := false
|
|
goodSamples := 0
|
|
lastSampleAt := time.Time{}
|
|
lastLoggedInterval := hwPollFloor
|
|
|
|
// PSU peak draw, sampled at a slow cadence off the same loop — the fan
|
|
// check already drives the box to full power, so it is also the right run
|
|
// to observe what each PSU tops out at (updatePSUObservation persists it).
|
|
psuPeakW := map[int]float64{}
|
|
lastPSUSec := -1e9
|
|
|
|
csvPath := filepath.Join(runDir, "fan-sensors.csv")
|
|
_ = os.WriteFile(csvPath, []byte("elapsed_sec,fan_name,rpm\n"), 0644)
|
|
csvFile, _ := os.OpenFile(csvPath, os.O_APPEND|os.O_WRONLY, 0644)
|
|
if csvFile != nil {
|
|
defer csvFile.Close()
|
|
}
|
|
|
|
loop:
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
aborted = true
|
|
break loop
|
|
case <-loadCtx.Done():
|
|
break loop // MaxLoadSec reached
|
|
case <-time.After(hwPollFloor):
|
|
}
|
|
elapsed := time.Since(start).Seconds()
|
|
|
|
curInterval := hwCurrentInterval()
|
|
if curInterval != lastLoggedInterval {
|
|
if curInterval > lastLoggedInterval {
|
|
degraded = true
|
|
appendSATVerboseLog(verboseLog, fmt.Sprintf("[%.0fs] ipmitool slow — shared polling backed off to %s",
|
|
elapsed, curInterval))
|
|
logFunc(fmt.Sprintf("IPMI slow — fan polling backed off to %s", curInterval))
|
|
}
|
|
lastLoggedInterval = curInterval
|
|
}
|
|
|
|
sample := hwSnapshot()
|
|
if sample.At.IsZero() || !sample.At.After(lastSampleAt) {
|
|
continue // no fresh reading from the shared poller yet
|
|
}
|
|
lastSampleAt = sample.At
|
|
goodSamples++
|
|
fans := sample.Fans
|
|
|
|
if elapsed-lastPSUSec >= 15 {
|
|
lastPSUSec = elapsed
|
|
for i, p := range sample.PSUs {
|
|
if p.PowerW > psuPeakW[i] {
|
|
psuPeakW[i] = p.PowerW
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, f := range fans {
|
|
if csvFile != nil {
|
|
fmt.Fprintf(csvFile, "%.0f,%s,%.0f\n", elapsed, f.Name, f.RPM)
|
|
}
|
|
st := fanBy[f.Name]
|
|
if st == nil {
|
|
fanBy[f.Name] = &fanState{peak: f.RPM, lastRiseSec: elapsed}
|
|
continue
|
|
}
|
|
if f.RPM > st.peak {
|
|
if f.RPM-st.peak > float64(opts.PlateauDeltaRPM) {
|
|
st.lastRiseSec = elapsed
|
|
}
|
|
st.peak = f.RPM
|
|
}
|
|
if f.RPM >= baselineRPM[f.Name]+float64(opts.RampConfirmRPM) {
|
|
rampConfirmed = true
|
|
}
|
|
}
|
|
|
|
// Only trust a plateau while telemetry is healthy and we have enough
|
|
// recent samples to have actually seen a flat window.
|
|
healthy := curInterval <= 2*hwPollFloor && goodSamples >= 5
|
|
if healthy && rampConfirmed && elapsed >= float64(opts.MinLoadSec) &&
|
|
time.Since(readyAt) >= time.Duration(opts.PlateauHoldSec)*time.Second && len(fanBy) > 0 {
|
|
allFlat := true
|
|
for _, st := range fanBy {
|
|
if elapsed-st.lastRiseSec < float64(opts.PlateauHoldSec) {
|
|
allFlat = false
|
|
break
|
|
}
|
|
}
|
|
if allFlat {
|
|
plateauReached = true
|
|
logFunc(fmt.Sprintf("All %d fans plateaued at %.0fs of load", len(fanBy), elapsed))
|
|
break loop
|
|
}
|
|
}
|
|
}
|
|
|
|
loadCancel()
|
|
loadWG.Wait()
|
|
|
|
if aborted && ctx.Err() != nil {
|
|
_ = os.WriteFile(filepath.Join(runDir, "summary.txt"),
|
|
[]byte("run_at_utc="+time.Now().UTC().Format(time.RFC3339)+"\noverall_status=UNKNOWN\naborted=true\n"), 0644)
|
|
return runDir, ctx.Err()
|
|
}
|
|
|
|
loadDur := time.Since(start).Seconds()
|
|
|
|
// ── Verdict.
|
|
statuses := readFanStatuses()
|
|
var summary strings.Builder
|
|
fmt.Fprintf(&summary, "run_at_utc=%s\n", time.Now().UTC().Format(time.RFC3339))
|
|
fmt.Fprintf(&summary, "fans_total=%d\n", len(baseFans))
|
|
fmt.Fprintf(&summary, "active_load_sources=%d\n", activeLoads)
|
|
fmt.Fprintf(&summary, "gpu_vendor=%s\n", orNone(haveGPU, vendor))
|
|
fmt.Fprintf(&summary, "plateau_reached=%v\n", plateauReached)
|
|
fmt.Fprintf(&summary, "ramp_confirmed=%v\n", rampConfirmed)
|
|
fmt.Fprintf(&summary, "load_duration_sec=%.0f\n", loadDur)
|
|
fmt.Fprintf(&summary, "fan_samples=%d\n", goodSamples)
|
|
fmt.Fprintf(&summary, "telemetry_degraded=%v\n", degraded)
|
|
if t := boundedGPUMaxTemp(opts.GPUIndices); t > 0 {
|
|
fmt.Fprintf(&summary, "gpu_temp_c=%.0f\n", t)
|
|
}
|
|
if len(psuPeakW) > 0 {
|
|
idx := make([]int, 0, len(psuPeakW))
|
|
for i := range psuPeakW {
|
|
idx = append(idx, i)
|
|
}
|
|
sort.Ints(idx)
|
|
for _, i := range idx {
|
|
fmt.Fprintf(&summary, "psu_%d_peak_w=%.0f\n", i, psuPeakW[i])
|
|
}
|
|
}
|
|
|
|
stats := satStats{}
|
|
names := make([]string, 0, len(baselineRPM))
|
|
for n := range baselineRPM {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
peak := baselineRPM[name]
|
|
if st := fanBy[name]; st != nil {
|
|
peak = st.peak
|
|
}
|
|
st := strings.ToLower(strings.TrimSpace(statuses[name]))
|
|
bad := peak <= 0 || st == "cr" || st == "nr"
|
|
key := sanitizeSummaryKey(name)
|
|
fmt.Fprintf(&summary, "fan_%s_baseline_rpm=%.0f\n", key, baselineRPM[name])
|
|
fmt.Fprintf(&summary, "fan_%s_max_rpm=%.0f\n", key, peak)
|
|
if bad {
|
|
reason := "0 RPM under load"
|
|
if st == "cr" || st == "nr" {
|
|
reason = "IPMI status " + st
|
|
}
|
|
fmt.Fprintf(&summary, "fan_%s_status=FAILED (%s)\n", key, reason)
|
|
logFunc(fmt.Sprintf("FAIL %s: %s", name, reason))
|
|
stats.Failed++
|
|
} else {
|
|
fmt.Fprintf(&summary, "fan_%s_status=OK\n", key)
|
|
stats.OK++
|
|
}
|
|
}
|
|
writeSATStats(&summary, stats)
|
|
|
|
if err := os.WriteFile(filepath.Join(runDir, "summary.txt"), []byte(summary.String()), 0644); err != nil {
|
|
return "", err
|
|
}
|
|
return runDir, nil
|
|
}
|
|
|
|
func errString(err error) string {
|
|
if err == nil {
|
|
return "no GPU stress tool"
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
func minDuration(a, b time.Duration) time.Duration {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func maxDuration(a, b time.Duration) time.Duration {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// boundedGPUMaxTemp returns the hottest GPU temperature via a single
|
|
// time-boxed nvidia-smi call, or 0 if unavailable.
|
|
func boundedGPUMaxTemp(gpuIndices []int) float64 {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
|
defer cancel()
|
|
args := []string{"--query-gpu=temperature.gpu", "--format=csv,noheader,nounits"}
|
|
if len(gpuIndices) > 0 {
|
|
ids := make([]string, len(gpuIndices))
|
|
for i, idx := range gpuIndices {
|
|
ids[i] = strconv.Itoa(idx)
|
|
}
|
|
args = append([]string{"--id=" + strings.Join(ids, ",")}, args...)
|
|
}
|
|
out, err := exec.CommandContext(ctx, "nvidia-smi", args...).Output()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
var max float64
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
if v, err := strconv.ParseFloat(strings.TrimSpace(line), 64); err == nil && v > max {
|
|
max = v
|
|
}
|
|
}
|
|
return max
|
|
}
|
|
|
|
// buildFanCheckGPULoadCmd builds the hottest sustained GPU load for the fan
|
|
// check. NVIDIA uses the Power/Thermal Fit engine (dcgmproftester -t 1004 /
|
|
// targeted_power); AMD uses the RVS gst stressor.
|
|
func buildFanCheckGPULoadCmd(ctx context.Context, vendor string, durSec int, gpuIndices []int) (*exec.Cmd, string, error) {
|
|
switch strings.ToLower(vendor) {
|
|
case "nvidia":
|
|
argv, env, err := resolveBenchmarkPowerLoadCommand(durSec, gpuIndices)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
|
if len(env) > 0 {
|
|
cmd.Env = append(os.Environ(), env...)
|
|
}
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
cmd.Cancel = func() error {
|
|
if cmd.Process != nil {
|
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
}
|
|
return nil
|
|
}
|
|
return cmd, "dcgmproftester targeted_power", nil
|
|
case "amd":
|
|
cmd := buildAMDGPUStressCmd(ctx, durSec)
|
|
if cmd == nil {
|
|
return nil, "", nil
|
|
}
|
|
return cmd, "rvs gst", nil
|
|
}
|
|
return nil, "", nil
|
|
}
|
|
|
|
func applyFanCheckDefaults(o *FanCheckOptions) {
|
|
if o.PlateauHoldSec <= 0 {
|
|
o.PlateauHoldSec = 60
|
|
}
|
|
if o.PlateauDeltaRPM <= 0 {
|
|
o.PlateauDeltaRPM = 50
|
|
}
|
|
if o.MinLoadSec <= 0 {
|
|
o.MinLoadSec = 90
|
|
}
|
|
if o.MaxLoadSec <= 0 {
|
|
o.MaxLoadSec = 900
|
|
}
|
|
if o.RampConfirmRPM <= 0 {
|
|
o.RampConfirmRPM = 150
|
|
}
|
|
if o.MinLoadSec < o.PlateauHoldSec {
|
|
o.MinLoadSec = o.PlateauHoldSec
|
|
}
|
|
if o.MaxLoadSec <= o.MinLoadSec {
|
|
o.MaxLoadSec = o.MinLoadSec + o.PlateauHoldSec
|
|
}
|
|
}
|
|
|
|
func orNone(present bool, v string) string {
|
|
if present && v != "" {
|
|
return v
|
|
}
|
|
return "none"
|
|
}
|
|
|
|
// sanitizeSummaryKey makes a fan sensor name safe as a summary.txt key
|
|
// fragment (keys are parsed by splitting on '=' and whitespace).
|
|
func sanitizeSummaryKey(name string) string {
|
|
var b strings.Builder
|
|
for _, r := range name {
|
|
switch {
|
|
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteByte('_')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// readFanStatuses returns the per-fan IPMI status word ("ok", "cr", "nr", ...)
|
|
// from the shared hardware telemetry cache (hwSnapshot) instead of shelling
|
|
// out to ipmitool itself — see hw_telemetry.go for why.
|
|
func readFanStatuses() map[string]string {
|
|
return hwSnapshot().FanStatus
|
|
}
|
|
|
|
// parseFanStatuses parses the per-fan IPMI status word out of `ipmitool sdr`
|
|
// text (the full dump or a "type Fan"-filtered one — both use the same
|
|
// per-line format). Non-fan lines end up in the map too (keyed by whatever
|
|
// sensor name they have) but that's harmless: callers only look up known fan
|
|
// names.
|
|
func parseFanStatuses(raw string) map[string]string {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
m := map[string]string{}
|
|
for _, line := range strings.Split(raw, "\n") {
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 3 {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(parts[0])
|
|
if name == "" {
|
|
continue
|
|
}
|
|
m[name] = strings.ToLower(strings.TrimSpace(parts[2]))
|
|
}
|
|
return m
|
|
}
|
|
|
|
// ResolveFanMaxRPM returns, for every fan name in current (name -> current
|
|
// RPM), the RPM to treat as that fan's 100% reference. Preference order:
|
|
// 1. the persisted observed peak, written by RunFanCheck and by live-metrics
|
|
// sampling under load;
|
|
// 2. the largest peak observed on any peer fan (keeps a group visually
|
|
// consistent when only some fans have a recorded peak);
|
|
// 3. the fan's own current RPM (so a tile is never sized against zero).
|
|
//
|
|
// The fallback lives here, not in the view, so every consumer of a fan
|
|
// maximum applies the same rule.
|
|
func ResolveFanMaxRPM(current map[string]float64) map[string]float64 {
|
|
persisted := fanPeaks.snapshot()
|
|
|
|
peerMax := 0.0
|
|
for _, v := range persisted {
|
|
if v > peerMax {
|
|
peerMax = v
|
|
}
|
|
}
|
|
|
|
out := make(map[string]float64, len(current))
|
|
for name, rpm := range current {
|
|
switch {
|
|
case persisted[name] > 0:
|
|
out[name] = persisted[name]
|
|
case peerMax > 0:
|
|
out[name] = peerMax
|
|
default:
|
|
out[name] = rpm
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sampleFanSpeeds reads fan RPM values from the shared hardware telemetry
|
|
// cache (hwSnapshot) instead of shelling out to ipmitool itself — see
|
|
// hw_telemetry.go for why. Falls back to lm-sensors when IPMI has nothing
|
|
// (unavailable BMC, or a platform without IPMI fan sensors at all).
|
|
func sampleFanSpeeds() ([]FanReading, error) {
|
|
if fans := hwSnapshot().Fans; len(fans) > 0 {
|
|
return fans, nil
|
|
}
|
|
fans, sensorsErr := sampleFanSpeedsViaSensorsJSON()
|
|
if len(fans) > 0 {
|
|
updateFanObservation(fans, time.Now())
|
|
return fans, nil
|
|
}
|
|
return nil, sensorsErr
|
|
}
|
|
|
|
// ObservedFanMaxRPM returns the per-fan observed peak RPM map persisted by
|
|
// full-load runs, or nil if none is recorded yet.
|
|
func ObservedFanMaxRPM() map[string]float64 {
|
|
out := fanPeaks.snapshot()
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func updateFanObservation(fans []FanReading, now time.Time) {
|
|
if len(fans) == 0 {
|
|
return
|
|
}
|
|
m := make(map[string]float64, len(fans))
|
|
for _, fan := range fans {
|
|
if n := strings.TrimSpace(fan.Name); n != "" && fan.RPM > 0 {
|
|
m[n] = fan.RPM
|
|
}
|
|
}
|
|
fanPeaks.observe(m, now)
|
|
}
|
|
|
|
func estimateFanDutyCyclePctFromObservation(fans []FanReading) (float64, bool) {
|
|
if len(fans) == 0 {
|
|
return 0, false
|
|
}
|
|
maxByName := fanPeaks.snapshot()
|
|
var samples []float64
|
|
for _, fan := range fans {
|
|
name := strings.TrimSpace(fan.Name)
|
|
if name == "" || fan.RPM <= 0 {
|
|
continue
|
|
}
|
|
maxRPM := maxByName[name]
|
|
if maxRPM <= 0 {
|
|
continue
|
|
}
|
|
pct := fan.RPM / maxRPM * 100.0
|
|
if pct > 100 {
|
|
pct = 100
|
|
}
|
|
if pct < 0 {
|
|
pct = 0
|
|
}
|
|
samples = append(samples, pct)
|
|
}
|
|
if len(samples) == 0 {
|
|
return 0, false
|
|
}
|
|
return benchmarkMean(samples), true
|
|
}
|
|
|
|
// parseFanSpeeds parses "ipmitool sdr type Fan" output.
|
|
// Handles two formats:
|
|
//
|
|
// Old: "FAN1 | 2400.000 | RPM | ok" (value in col[1], unit in col[2])
|
|
// New: "FAN1 | 41h | ok | 29.1 | 4340 RPM" (value+unit combined in last col)
|
|
func parseFanSpeeds(raw string) []FanReading {
|
|
var fans []FanReading
|
|
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 2 {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(parts[0])
|
|
// Find the first field that contains "RPM" (either as a standalone unit or inline)
|
|
rpmVal := 0.0
|
|
found := false
|
|
for _, p := range parts[1:] {
|
|
p = strings.TrimSpace(p)
|
|
if !strings.Contains(strings.ToUpper(p), "RPM") {
|
|
continue
|
|
}
|
|
if strings.EqualFold(p, "RPM") {
|
|
continue // unit-only column in old format; value is in previous field
|
|
}
|
|
val, err := parseFanRPMValue(p)
|
|
if err == nil {
|
|
rpmVal = val
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
// Old format: unit "RPM" is in col[2], value is in col[1]
|
|
if !found && len(parts) >= 3 && strings.EqualFold(strings.TrimSpace(parts[2]), "RPM") {
|
|
valStr := strings.TrimSpace(parts[1])
|
|
if !strings.EqualFold(valStr, "na") && !strings.EqualFold(valStr, "disabled") && valStr != "" {
|
|
if val, err := parseFanRPMValue(valStr); err == nil {
|
|
rpmVal = val
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
continue
|
|
}
|
|
fans = append(fans, FanReading{Name: name, RPM: rpmVal})
|
|
}
|
|
return fans
|
|
}
|
|
|
|
func parseFanRPMValue(raw string) (float64, error) {
|
|
fields := strings.Fields(strings.TrimSpace(strings.ReplaceAll(raw, ",", "")))
|
|
if len(fields) == 0 {
|
|
return 0, strconv.ErrSyntax
|
|
}
|
|
return strconv.ParseFloat(fields[0], 64)
|
|
}
|
|
|
|
func sampleFanSpeedsViaSensorsJSON() ([]FanReading, error) {
|
|
out, err := exec.Command("sensors", "-j").Output()
|
|
if err != nil || len(out) == 0 {
|
|
return nil, err
|
|
}
|
|
var doc map[string]map[string]any
|
|
if err := json.Unmarshal(out, &doc); err != nil {
|
|
return nil, err
|
|
}
|
|
chips := make([]string, 0, len(doc))
|
|
for chip := range doc {
|
|
chips = append(chips, chip)
|
|
}
|
|
sort.Strings(chips)
|
|
var fans []FanReading
|
|
seen := map[string]struct{}{}
|
|
for _, chip := range chips {
|
|
features := doc[chip]
|
|
names := make([]string, 0, len(features))
|
|
for name := range features {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
feature, ok := features[name].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
rpm, ok := firstFanInputValue(feature)
|
|
if !ok || rpm <= 0 {
|
|
continue
|
|
}
|
|
label := strings.TrimSpace(name)
|
|
if chip != "" && !strings.Contains(strings.ToLower(label), strings.ToLower(chip)) {
|
|
label = chip + " / " + label
|
|
}
|
|
if _, ok := seen[label]; ok {
|
|
continue
|
|
}
|
|
seen[label] = struct{}{}
|
|
fans = append(fans, FanReading{Name: label, RPM: rpm})
|
|
}
|
|
}
|
|
return fans, nil
|
|
}
|
|
|
|
func sampleFanDutyCyclePctFromFans(fans []FanReading) (float64, bool, bool) {
|
|
if len(fans) == 0 {
|
|
return 0, false, false
|
|
}
|
|
if pct, ok := estimateFanDutyCyclePctFromObservation(fans); ok {
|
|
return pct, true, true
|
|
}
|
|
return 0, false, false
|
|
}
|
|
|
|
func parseFanDutyCyclePctSensorsJSON(raw []byte) (float64, bool) {
|
|
var doc map[string]map[string]any
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
return 0, false
|
|
}
|
|
var samples []float64
|
|
for _, features := range doc {
|
|
for name, feature := range features {
|
|
if strings.EqualFold(name, "Adapter") {
|
|
continue
|
|
}
|
|
featureMap, ok := feature.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if duty, ok := firstFanDutyValue(name, featureMap); ok {
|
|
samples = append(samples, duty)
|
|
}
|
|
}
|
|
}
|
|
if len(samples) == 0 {
|
|
return 0, false
|
|
}
|
|
return benchmarkMean(samples), true
|
|
}
|
|
|
|
func firstFanDutyValue(featureName string, feature map[string]any) (float64, bool) {
|
|
featureName = strings.ToLower(strings.TrimSpace(featureName))
|
|
if strings.Contains(featureName, "enable") || strings.Contains(featureName, "mode") || strings.Contains(featureName, "alarm") {
|
|
return 0, false
|
|
}
|
|
if strings.Contains(featureName, "pwm") {
|
|
for _, key := range []string{"input", "value", "current"} {
|
|
if value, ok := feature[key]; ok {
|
|
if duty, parsed := parseFanDutyValue(value); parsed {
|
|
return duty, true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
keys := make([]string, 0, len(feature))
|
|
for key := range feature {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
lower := strings.ToLower(key)
|
|
if !strings.Contains(lower, "pwm") {
|
|
continue
|
|
}
|
|
if strings.Contains(lower, "enable") || strings.Contains(lower, "mode") || strings.Contains(lower, "alarm") {
|
|
continue
|
|
}
|
|
if duty, parsed := parseFanDutyValue(feature[key]); parsed {
|
|
return duty, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func parseFanDutyValue(value any) (float64, bool) {
|
|
switch v := value.(type) {
|
|
case float64:
|
|
return normalizePWMAsDutyPct(v)
|
|
case string:
|
|
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
|
return normalizePWMAsDutyPct(f)
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func normalizePWMAsDutyPct(raw float64) (float64, bool) {
|
|
if raw < 0 {
|
|
return 0, false
|
|
}
|
|
if raw <= 100 {
|
|
return raw, true
|
|
}
|
|
if raw <= 255 {
|
|
return raw / 255.0 * 100.0, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func firstFanInputValue(feature map[string]any) (float64, bool) {
|
|
return firstSensorInputValue(feature, "fan")
|
|
}
|
|
|
|
// sampleCPUMaxTemp returns the highest CPU/inlet temperature, from the
|
|
// shared hardware telemetry cache (hwSnapshot) when IPMI has it, else
|
|
// lm-sensors directly.
|
|
func sampleCPUMaxTemp() float64 {
|
|
if raw := hwSnapshot().Raw; raw != "" {
|
|
if t := parseIPMIMaxTemp(raw); t > 0 {
|
|
return t
|
|
}
|
|
}
|
|
return sampleCPUTempViaSensors()
|
|
}
|
|
|
|
// parseIPMIMaxTemp extracts the maximum temperature from "ipmitool sdr type Temperature".
|
|
func parseIPMIMaxTemp(raw string) float64 {
|
|
var max float64
|
|
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 3 {
|
|
continue
|
|
}
|
|
unit := strings.TrimSpace(parts[2])
|
|
if !strings.Contains(strings.ToLower(unit), "degrees") {
|
|
continue
|
|
}
|
|
valStr := strings.TrimSpace(parts[1])
|
|
if strings.EqualFold(valStr, "na") || valStr == "" {
|
|
continue
|
|
}
|
|
val, err := strconv.ParseFloat(valStr, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if val > max {
|
|
max = val
|
|
}
|
|
}
|
|
return max
|
|
}
|
|
|
|
// sampleCPUTempViaSensors falls back to lm-sensors when ipmitool is unavailable.
|
|
func sampleCPUTempViaSensors() float64 {
|
|
out, err := exec.Command("sensors", "-u").Output()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
var max float64
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(fields[0], "_input:") {
|
|
continue
|
|
}
|
|
val, err := strconv.ParseFloat(fields[1], 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if val > 0 && val < 150 && val > max {
|
|
max = val
|
|
}
|
|
}
|
|
return max
|
|
}
|
|
|
|
// parseDCMIPowerReading extracts the instantaneous power reading from ipmitool dcmi output.
|
|
// Sample: " Instantaneous power reading: 500 Watts"
|
|
func parseDCMIPowerReading(raw string) float64 {
|
|
for _, line := range strings.Split(raw, "\n") {
|
|
if !strings.Contains(strings.ToLower(line), "instantaneous") {
|
|
continue
|
|
}
|
|
parts := strings.Fields(line)
|
|
for i, p := range parts {
|
|
if strings.EqualFold(p, "Watts") && i > 0 {
|
|
val, err := strconv.ParseFloat(parts[i-1], 64)
|
|
if err == nil {
|
|
return val
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func effectiveSystemPowerReading(cache cachedPowerReading, current float64, source, mode, reason string, now time.Time) (float64, cachedPowerReading) {
|
|
if current > 0 {
|
|
cache = cachedPowerReading{Value: current, Source: source, Mode: mode, Reason: reason, UpdatedAt: now}
|
|
return current, cache
|
|
}
|
|
if cache.Value > 0 && !cache.UpdatedAt.IsZero() && now.Sub(cache.UpdatedAt) <= systemPowerHoldTTL {
|
|
return cache.Value, cache
|
|
}
|
|
return 0, cache
|
|
}
|
|
|
|
// fanRPMStats computes average, min, max RPM across all fans in a sample row.
|
|
func fanRPMStats(fans []FanReading) (avg, min, max float64) {
|
|
if len(fans) == 0 {
|
|
return 0, 0, 0
|
|
}
|
|
min = fans[0].RPM
|
|
max = fans[0].RPM
|
|
var total float64
|
|
for _, f := range fans {
|
|
total += f.RPM
|
|
if f.RPM < min {
|
|
min = f.RPM
|
|
}
|
|
if f.RPM > max {
|
|
max = f.RPM
|
|
}
|
|
}
|
|
return total / float64(len(fans)), min, max
|
|
}
|