package tests import ( "context" "fmt" "os" "path/filepath" "strconv" "strings" ) // PSU walks /sys/class/hwmon for in*_input (mV) and in*_label to find // PSU rails. In home-lab hosts the kernel surfaces a handful of named // rails (12V, 5V, 3V3). No rails → auto-skip. Any rail outside a ±10% // window of its nominal value → fail. func PSU(ctx context.Context, d Deps) Outcome { rails := scanPSURails() if len(rails) == 0 { d.Info("PSU: no voltage rails found under /sys/class/hwmon — skipping stage") return Outcome{ Passed: true, Summary: "skipped (no PSU sensors)", Extras: map[string]any{"skipped": true, "reason": "no_hwmon_voltages"}, } } var samples []Sample problems := []string{} for _, rail := range rails { samples = append(samples, Sample{Kind: "psu_volt", Key: rail.Label, Value: rail.Volts, Unit: "V"}) if ok, why := voltageInRange(rail); !ok { problems = append(problems, fmt.Sprintf("%s=%.2fV (%s)", rail.Label, rail.Volts, why)) } } if d.Sensor != nil { _ = d.Sensor(ctx, samples) } extras := map[string]any{ "rails": rails, "problems": problems, } if len(problems) > 0 { d.Error("PSU: out-of-range rails: " + strings.Join(problems, ", ")) return Outcome{ Passed: false, Message: "PSU rails out of range: " + strings.Join(problems, ", "), Summary: fmt.Sprintf("%d rails, %d failing", len(rails), len(problems)), Extras: extras, } } d.Info(fmt.Sprintf("PSU: %d rails within ±10%% nominal", len(rails))) return Outcome{ Passed: true, Summary: fmt.Sprintf("%d rails nominal", len(rails)), Extras: extras, } } type psuRail struct { Label string `json:"label"` Volts float64 `json:"volts"` } // scanPSURails walks every hwmon chip looking for in*_input files with // an accompanying in*_label that mentions a known rail name. Unknown // labels are skipped rather than flagged — motherboard VRMs report many // rails that aren't PSU outputs. func scanPSURails() []psuRail { root := "/sys/class/hwmon" chips, err := os.ReadDir(root) if err != nil { return nil } var out []psuRail for _, c := range chips { base := filepath.Join(root, c.Name()) files, err := os.ReadDir(base) if err != nil { continue } for _, f := range files { name := f.Name() if !strings.HasPrefix(name, "in") || !strings.HasSuffix(name, "_input") { continue } n := strings.TrimSuffix(strings.TrimPrefix(name, "in"), "_input") labelPath := filepath.Join(base, "in"+n+"_label") label := strings.TrimSpace(readFileStr(labelPath)) if !isPSULabel(label) { continue } raw := strings.TrimSpace(readFileStr(filepath.Join(base, name))) mv, err := strconv.Atoi(raw) if err != nil { continue } out = append(out, psuRail{Label: label, Volts: float64(mv) / 1000}) } } return out } // isPSULabel filters labels that look like PSU rails. Keeps a small // allowlist to avoid flagging CPU VRM rails as PSU failures. func isPSULabel(label string) bool { l := strings.ToLower(label) switch { case strings.Contains(l, "12v"), strings.Contains(l, "5v"), strings.Contains(l, "3.3v"), strings.Contains(l, "3v3"), strings.Contains(l, "vccin"): return true } return false } // voltageInRange returns (ok, reason). A label like "12V" has a 12.0V // nominal; we accept ±10%. Unknown labels pass. func voltageInRange(r psuRail) (bool, string) { nom := nominalFor(r.Label) if nom == 0 { return true, "" } delta := r.Volts - nom if delta < 0 { delta = -delta } if delta/nom > 0.10 { return false, fmt.Sprintf("expected ~%.1fV", nom) } return true, "" } func nominalFor(label string) float64 { l := strings.ToLower(label) switch { case strings.Contains(l, "12v"): return 12.0 case strings.Contains(l, "5v"): return 5.0 case strings.Contains(l, "3.3v"), strings.Contains(l, "3v3"): return 3.3 } return 0 } func readFileStr(p string) string { b, err := os.ReadFile(p) if err != nil { return "" } return string(b) }