package hub import ( "context" "os" "path/filepath" "sort" "strconv" "strings" ) // TempReader reads the host CPU/chassis temperature in whole °C, returning nil when no usable // sensor is exposed. It is the slice-9 collector seam — graceful-null is the contract: a missing // sensor, an unsupported board, or any read error all degrade to nil rather than failing the // host report (mirrors the nullable disk SmartSummary.TemperatureC). The collector is nil-safe // (a nil TempReader yields nil temp). type TempReader interface { CPUTempC(ctx context.Context) *int } // SysfsTempReader reads the CPU package temperature straight from sysfs (hwmon coretemp/k10temp/ // cpu_thermal, then the thermal zones). No external binary and no privilege is needed — these // nodes are world-readable — so it never shells out (keeping the agent's root-CLI fence intact). // It prefers the CPU-package hwmon sensor; only if hwmon yields nothing does it fall back to a // CPU-ish thermal zone. Every failure path returns nil ("n/a"). type SysfsTempReader struct { // Root overrides the sysfs root ("" → "/sys"); set by tests to a fake layout. Root string } func (r SysfsTempReader) root() string { if r.Root != "" { return r.Root } return "/sys" } // CPUTempC returns the CPU/chassis temperature in whole °C, or nil if nothing usable is exposed. // ctx is accepted for interface symmetry (the reads are local sysfs and effectively instant). func (r SysfsTempReader) CPUTempC(ctx context.Context) *int { if t := r.fromHwmon(); t != nil { return t } if t := r.fromThermalZones(); t != nil { return t } return nil } // cpuHwmonNames are the kernel hwmon driver names that expose a CPU temperature: coretemp // (Intel — e.g. the demo N100), k10temp/zenpower (AMD), cpu_thermal (ARM SoCs). var cpuHwmonNames = map[string]bool{ "coretemp": true, "k10temp": true, "zenpower": true, "cpu_thermal": true, } // fromHwmon scans /sys/class/hwmon/hwmon*/ for a CPU driver and returns its package temperature. // For a multi-core coretemp it prefers the "Package id 0" labelled input; otherwise it takes the // first readable tempN_input. Returns nil when no CPU hwmon is present/readable. func (r SysfsTempReader) fromHwmon() *int { dirs, err := filepath.Glob(filepath.Join(r.root(), "class", "hwmon", "hwmon*")) if err != nil { return nil } sort.Strings(dirs) // deterministic device ordering (hwmon0, hwmon1, …) for _, dir := range dirs { name := strings.TrimSpace(readFileTrim(filepath.Join(dir, "name"))) if !cpuHwmonNames[name] { continue } if t := readHwmonPackageTemp(dir); t != nil { return t } } return nil } // readHwmonPackageTemp returns a CPU hwmon device's package temperature, preferring a // tempN_input whose tempN_label is "Package id 0", else the lowest-numbered readable input. func readHwmonPackageTemp(dir string) *int { inputs, err := filepath.Glob(filepath.Join(dir, "temp*_input")) if err != nil || len(inputs) == 0 { return nil } sort.Strings(inputs) // temp1_input < temp10_input lexically is wrong, but the package is temp1 var firstReadable *int for _, in := range inputs { milli, ok := readMilliC(in) if !ok { continue } c := milli / 1000 if firstReadable == nil { v := c firstReadable = &v } labelPath := strings.TrimSuffix(in, "_input") + "_label" if strings.EqualFold(strings.TrimSpace(readFileTrim(labelPath)), "Package id 0") { v := c return &v } } return firstReadable } // cpuZoneTypes are thermal-zone `type` values that name a CPU sensor, in preference order. var cpuZoneTypes = []string{"x86_pkg_temp", "coretemp", "cpu-thermal", "cpu_thermal", "soc_thermal"} // fromThermalZones scans /sys/class/thermal/thermal_zone*/ and returns the best CPU-ish zone's // temperature. It prefers a zone whose `type` matches a known CPU sensor (in cpuZoneTypes order); // if none match it falls back to an acpitz zone, then the first readable zone. nil when none read. func (r SysfsTempReader) fromThermalZones() *int { zones, err := filepath.Glob(filepath.Join(r.root(), "class", "thermal", "thermal_zone*")) if err != nil { return nil } sort.Strings(zones) byType := map[string]*int{} var acpitz, firstAny *int for _, z := range zones { zType := strings.TrimSpace(readFileTrim(filepath.Join(z, "type"))) milli, ok := readMilliC(filepath.Join(z, "temp")) if !ok { continue } c := milli / 1000 if firstAny == nil { v := c firstAny = &v } if zType == "acpitz" && acpitz == nil { v := c acpitz = &v } if _, seen := byType[zType]; !seen { v := c byType[zType] = &v } } for _, want := range cpuZoneTypes { if t := byType[want]; t != nil { return t } } if acpitz != nil { return acpitz } return firstAny } // readMilliC reads a sysfs temperature file (millidegrees Celsius as an integer) and returns it. // A sane sanity bound rejects obviously bogus values (sensors occasionally report 0 or huge // numbers when not yet initialised) so "n/a" is reported instead of a garbage temperature. func readMilliC(path string) (int, bool) { s := readFileTrim(path) if s == "" { return 0, false } milli, err := strconv.Atoi(s) if err != nil { return 0, false } // Plausible CPU/chassis range: 5°C..150°C. Outside that → treat as unavailable. if milli < 5000 || milli > 150000 { return 0, false } return milli, true } // readFileTrim reads a small sysfs file and trims it; "" on any error (graceful-null). func readFileTrim(path string) string { b, err := os.ReadFile(path) if err != nil { return "" } return strings.TrimSpace(string(b)) }