package sensors import ( "encoding/json" "fmt" "math" "sort" "strconv" "strings" "github.com/rs/zerolog/log" ) // TemperatureData contains parsed temperature readings from sensors type TemperatureData struct { CPUPackage float64 // Overall CPU package temperature CPUMax float64 // Maximum CPU temperature Cores map[string]float64 // Per-core temperatures (e.g., "Core 0": 45.0) NVMe map[string]float64 // NVMe drive temperatures (e.g., "nvme0": 42.0) GPU map[string]float64 // GPU temperatures (e.g., "amdgpu-pci-0400": 55.0) Fans map[string]float64 // Fan speeds in RPM (e.g., "cpu_fan": 1200) Other map[string]float64 // Other temperatures (DDR5, mobo, etc.) Available bool // Whether any temperature data was found } // Parse extracts temperature data from sensors -j JSON output func Parse(jsonStr string) (*TemperatureData, error) { if strings.TrimSpace(jsonStr) == "" { return nil, fmt.Errorf("empty sensors output") } var sensorsData map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &sensorsData); err != nil { return nil, fmt.Errorf("failed to parse sensors JSON: %w", err) } data := &TemperatureData{ Cores: make(map[string]float64), NVMe: make(map[string]float64), GPU: make(map[string]float64), Fans: make(map[string]float64), Other: make(map[string]float64), } foundCPUChip := false nvmeTempsByChip := make(map[string]float64) // Parse each sensor chip for chipName, chipData := range sensorsData { chipMap, ok := chipData.(map[string]interface{}) if !ok { continue } chipLower := strings.ToLower(chipName) // Handle CPU temperature sensors if isCPUChip(chipLower) { foundCPUChip = true parseCPUTemps(chipMap, data) } // Handle NVMe temperature sensors if strings.Contains(chipLower, "nvme") { if tempVal, ok := extractNVMeCompositeTemp(chipMap); ok { nvmeTempsByChip[chipName] = tempVal } } // Handle GPU temperature sensors if strings.Contains(chipLower, "amdgpu") || strings.Contains(chipLower, "nouveau") { parseGPUTemps(chipName, chipMap, data) } // Parse all fans and other temperatures from every chip parseFansAndOther(chipName, chipMap, data) } // If we got CPU temps, calculate max from cores if package not available if data.CPUPackage == 0 && len(data.Cores) > 0 { for _, temp := range data.Cores { if temp > data.CPUMax { data.CPUMax = temp } } // Use max core temp as package temp if not available data.CPUPackage = data.CPUMax } if len(nvmeTempsByChip) > 0 { chips := make([]string, 0, len(nvmeTempsByChip)) for chip := range nvmeTempsByChip { chips = append(chips, chip) } sort.Strings(chips) for i, chip := range chips { normalizedName := fmt.Sprintf("nvme%d", i) data.NVMe[normalizedName] = nvmeTempsByChip[chip] log.Debug(). Str("chip", chip). Str("normalizedName", normalizedName). Float64("temp", nvmeTempsByChip[chip]). Msg("Found NVMe temperature") } } data.Available = foundCPUChip || len(data.NVMe) > 0 || len(data.GPU) > 0 || len(data.Fans) > 0 || len(data.Other) > 0 log.Debug(). Bool("available", data.Available). Float64("cpuPackage", data.CPUPackage). Float64("cpuMax", data.CPUMax). Int("coreCount", len(data.Cores)). Int("nvmeCount", len(data.NVMe)). Int("gpuCount", len(data.GPU)). Int("fanCount", len(data.Fans)). Int("otherCount", len(data.Other)). Msg("Parsed temperature data") return data, nil } func isCPUChip(chipLower string) bool { cpuChips := []string{ "coretemp", "k10temp", "zenpower", "k8temp", "acpitz", "it87", "nct6687", "nct6775", "nct6776", "nct6779", "nct6791", "nct6792", "nct6793", "nct6795", "nct6796", "nct6797", "nct6798", "w83627", "f71882", "cpu_thermal", "rp1_adc", "rpitemp", "armada_thermal", } for _, chip := range cpuChips { if strings.Contains(chipLower, chip) { return true } } return false } func parseCPUTemps(chipMap map[string]interface{}, data *TemperatureData) { foundPackageTemp := false var chipletTemps []float64 var genericTemp float64 // For chips that only report temp1 for sensorName, sensorData := range chipMap { sensorMap, ok := sensorData.(map[string]interface{}) if !ok { continue } sensorNameLower := strings.ToLower(sensorName) // Look for Package id (Intel) or Tdie/Tctl (AMD) if strings.Contains(sensorName, "Package id") || strings.Contains(sensorName, "Tdie") || strings.Contains(sensorNameLower, "tctl") { if tempVal := extractTempInput(sensorMap); !math.IsNaN(tempVal) { data.CPUPackage = tempVal foundPackageTemp = true if tempVal > data.CPUMax { data.CPUMax = tempVal } } } // Capture generic temp1 for chips like cpu_thermal (RPi, ARM SoCs) // that don't have labeled sensors if sensorNameLower == "temp1" { if tempVal := extractTempInput(sensorMap); !math.IsNaN(tempVal) && tempVal > 0 && tempVal < 150 { genericTemp = tempVal } } // Look for AMD chiplet temperatures if strings.HasPrefix(sensorName, "Tccd") { if tempVal := extractTempInput(sensorMap); !math.IsNaN(tempVal) && tempVal > 0 { chipletTemps = append(chipletTemps, tempVal) if tempVal > data.CPUMax { data.CPUMax = tempVal } } } // Look for SuperIO chip CPU temperature fields if strings.Contains(sensorNameLower, "cputin") || strings.Contains(sensorNameLower, "cpu temperature") || (strings.Contains(sensorNameLower, "temp") && strings.Contains(sensorNameLower, "cpu")) { if tempVal := extractTempInput(sensorMap); !math.IsNaN(tempVal) && tempVal > 0 { if !foundPackageTemp { data.CPUPackage = tempVal foundPackageTemp = true } if tempVal > data.CPUMax { data.CPUMax = tempVal } } } // Look for individual core temperatures if strings.Contains(sensorName, "Core ") { if tempVal := extractTempInput(sensorMap); !math.IsNaN(tempVal) { data.Cores[sensorName] = tempVal if tempVal > data.CPUMax { data.CPUMax = tempVal } } } } // If no package temp but we have chiplet temps, use highest chiplet if !foundPackageTemp && len(chipletTemps) > 0 { for _, temp := range chipletTemps { if temp > data.CPUPackage { data.CPUPackage = temp } } } // Fallback: use generic temp1 for chips like cpu_thermal (RPi, ARM SoCs) if !foundPackageTemp && data.CPUPackage == 0 && genericTemp > 0 { data.CPUPackage = genericTemp if genericTemp > data.CPUMax { data.CPUMax = genericTemp } } } func extractNVMeCompositeTemp(chipMap map[string]interface{}) (float64, bool) { for sensorName, sensorData := range chipMap { sensorMap, ok := sensorData.(map[string]interface{}) if !ok { continue } // Look for Composite temperature (main NVMe temp) if strings.Contains(sensorName, "Composite") { if tempVal := extractTempInput(sensorMap); !math.IsNaN(tempVal) && tempVal > 0 { return tempVal, true } } } return 0, false } func parseGPUTemps(chipName string, chipMap map[string]interface{}, data *TemperatureData) { for sensorName, sensorData := range chipMap { sensorMap, ok := sensorData.(map[string]interface{}) if !ok { continue } sensorNameLower := strings.ToLower(sensorName) // Look for GPU temperature fields if strings.Contains(sensorNameLower, "edge") || strings.Contains(sensorNameLower, "junction") || strings.Contains(sensorNameLower, "mem") || strings.Contains(sensorNameLower, "temp1") { if tempVal := extractTempInput(sensorMap); !math.IsNaN(tempVal) { // Use sensor name as key (e.g., "edge", "junction") key := fmt.Sprintf("%s_%s", chipName, sensorName) data.GPU[key] = tempVal log.Debug(). Str("chip", chipName). Str("sensor", sensorName). Float64("temp", tempVal). Msg("Found GPU temperature") } } } } func extractTempInput(sensorMap map[string]interface{}) float64 { // Look for temp*_input field (the actual temperature reading) for key, value := range sensorMap { if strings.HasSuffix(key, "_input") { switch v := value.(type) { case float64: return normalizeTemperatureValue(v) case int: return normalizeTemperatureValue(float64(v)) case string: if parsed, ok := parseStringTemperature(v); ok { return parsed } } } } return math.NaN() } func normalizeTemperatureValue(value float64) float64 { // Some sensor backends report temperatures in millidegrees (e.g. 42000). // Convert only when magnitude strongly indicates millidegrees. if math.Abs(value) >= 1000 { return value / 1000.0 } return value } // parseStringTemperature parses numeric string temperature values. // It preserves normal degree values (e.g., "45.0", "+45.0C") and only converts // probable millidegree values (e.g., "42000") down to degrees. func parseStringTemperature(value string) (float64, bool) { value = strings.TrimSpace(value) if value == "" { return 0, false } parsed, err := strconv.ParseFloat(value, 64) if err != nil { // Fall back to parsing numeric prefixes such as "+45.0°C". if _, scanErr := fmt.Sscanf(value, "%f", &parsed); scanErr != nil { return 0, false } } return normalizeTemperatureValue(parsed), true } // parseFansAndOther extracts fan speeds and other temperature readings from a sensor chip. // This captures DDR5/RAM temps, motherboard temps, additional NVMe sensors, fan speeds, etc. func parseFansAndOther(chipName string, chipMap map[string]interface{}, data *TemperatureData) { chipLower := strings.ToLower(chipName) for sensorName, sensorData := range chipMap { sensorMap, ok := sensorData.(map[string]interface{}) if !ok { continue } sensorNameLower := strings.ToLower(sensorName) // Extract fan speeds (fan*_input fields report RPM) for key, value := range sensorMap { keyLower := strings.ToLower(key) // Fan speed readings end in _input and are under fan* sensors if strings.HasPrefix(keyLower, "fan") && strings.HasSuffix(keyLower, "_input") { rpm := extractNumericValue(value) if rpm > 0 { // Create a readable key: chipName_sensorName (e.g., "nct6687_cpu_fan") fanKey := normalizeSensorKey(chipName, sensorName) data.Fans[fanKey] = rpm log.Debug(). Str("chip", chipName). Str("sensor", sensorName). Float64("rpm", rpm). Msg("Found fan speed") } } } // Skip sensors we've already handled in specific parsers if isCPUChip(chipLower) { // Skip CPU temps - already handled if strings.Contains(sensorName, "Package") || strings.Contains(sensorName, "Core ") || strings.Contains(sensorNameLower, "tdie") || strings.Contains(sensorNameLower, "tctl") || strings.HasPrefix(sensorName, "Tccd") || strings.Contains(sensorNameLower, "cputin") { continue } } if strings.Contains(chipLower, "nvme") && strings.Contains(sensorName, "Composite") { continue // Already handled } if (strings.Contains(chipLower, "amdgpu") || strings.Contains(chipLower, "nouveau")) && (strings.Contains(sensorNameLower, "edge") || strings.Contains(sensorNameLower, "junction") || strings.Contains(sensorNameLower, "mem")) { continue // Already handled as GPU temps } // Extract other temperature readings (DDR5, motherboard, etc.) tempVal := extractTempInput(sensorMap) if !math.IsNaN(tempVal) && tempVal > 0 && tempVal < 150 { // Sanity check: 0-150°C range tempKey := normalizeSensorKey(chipName, sensorName) // Avoid duplicating temps already in other maps if _, exists := data.Other[tempKey]; !exists { data.Other[tempKey] = tempVal log.Debug(). Str("chip", chipName). Str("sensor", sensorName). Float64("temp", tempVal). Msg("Found other temperature") } } } } // normalizeSensorKey creates a readable, normalized key from chip and sensor names. // e.g., "nct6687-isa-0a20" + "CPU Fan" -> "nct6687_cpu_fan" func normalizeSensorKey(chipName, sensorName string) string { // Strip the address suffix from chip names (e.g., "-isa-0a20", "-pci-0400") chipClean := chipName if idx := strings.Index(chipName, "-"); idx > 0 { // Keep just the chip type (e.g., "nct6687", "amdgpu") chipClean = chipName[:idx] } // Normalize sensor name: lowercase, replace spaces with underscores sensorClean := strings.ToLower(sensorName) sensorClean = strings.ReplaceAll(sensorClean, " ", "_") sensorClean = strings.ReplaceAll(sensorClean, "-", "_") return fmt.Sprintf("%s_%s", strings.ToLower(chipClean), sensorClean) } // extractNumericValue extracts a numeric value from an interface{} func extractNumericValue(value interface{}) float64 { switch v := value.(type) { case float64: return v case int: return float64(v) case int64: return float64(v) } return 0 }