mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-29 03:50:18 +00:00
421 lines
12 KiB
Go
421 lines
12 KiB
Go
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",
|
|
}
|
|
|
|
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 {
|
|
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 v
|
|
case int:
|
|
return float64(v)
|
|
case string:
|
|
if parsed, ok := parseStringTemperature(v); ok {
|
|
return parsed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return math.NaN()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// lm-sensors fallback on some platforms can report millidegrees as raw strings.
|
|
// Convert only when the magnitude strongly indicates millidegrees.
|
|
if math.Abs(parsed) >= 1000 {
|
|
parsed = parsed / 1000.0
|
|
}
|
|
|
|
return 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
|
|
}
|