package main import ( "context" "errors" "fmt" "os" "path/filepath" "runtime" "strconv" "strings" "time" ) var ( // Cache for heavy system_profiler output. lastPowerAt time.Time cachedPower string powerCacheTTL = 30 * time.Second ) func collectBatteries() (batts []BatteryStatus, err error) { defer func() { if r := recover(); r != nil { // Swallow panics to keep UI alive. err = fmt.Errorf("battery collection failed: %v", r) } }() // macOS: pmset for real-time percentage/status. if runtime.GOOS == "darwin" && commandExists("pmset") { if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil { // Health/cycles/capacity from cached system_profiler. health, cycles, capacity := getCachedPowerData() if batts := parsePMSet(out, health, cycles, capacity); len(batts) > 0 { return batts, nil } } } // Linux: /sys/class/power_supply. matches, _ := filepath.Glob("/sys/class/power_supply/BAT*/capacity") for _, capFile := range matches { statusFile := filepath.Join(filepath.Dir(capFile), "status") capData, err := os.ReadFile(capFile) if err != nil { continue } statusData, _ := os.ReadFile(statusFile) percentStr := strings.TrimSpace(string(capData)) percent, _ := strconv.ParseFloat(percentStr, 64) status := strings.TrimSpace(string(statusData)) if status == "" { status = "Unknown" } batts = append(batts, BatteryStatus{ Percent: percent, Status: status, }) } if len(batts) > 0 { return batts, nil } return nil, errors.New("no battery data found") } func parsePMSet(raw string, health string, cycles int, capacity int) []BatteryStatus { var out []BatteryStatus var timeLeft string for line := range strings.Lines(raw) { // Time remaining. if strings.Contains(line, "remaining") { parts := strings.Fields(line) for i, p := range parts { if p == "remaining" && i > 0 { timeLeft = parts[i-1] } } } if !strings.Contains(line, "%") { continue } fields := strings.Fields(line) var ( percent float64 found bool status = "Unknown" ) for i, f := range fields { if strings.Contains(f, "%") { value := strings.TrimSuffix(strings.TrimSuffix(f, ";"), "%") if p, err := strconv.ParseFloat(value, 64); err == nil { percent = p found = true if i+1 < len(fields) { status = strings.TrimSuffix(fields[i+1], ";") } } break } } if !found { continue } out = append(out, BatteryStatus{ Percent: percent, Status: status, TimeLeft: timeLeft, Health: health, CycleCount: cycles, Capacity: capacity, }) } return out } // getCachedPowerData returns condition, cycles, and capacity from cached system_profiler. func getCachedPowerData() (health string, cycles int, capacity int) { out := getSystemPowerOutput() if out == "" { return "", 0, 0 } for line := range strings.Lines(out) { lower := strings.ToLower(line) if strings.Contains(lower, "cycle count") { if _, after, found := strings.Cut(line, ":"); found { cycles, _ = strconv.Atoi(strings.TrimSpace(after)) } } if strings.Contains(lower, "condition") { if _, after, found := strings.Cut(line, ":"); found { health = strings.TrimSpace(after) } } if strings.Contains(lower, "maximum capacity") { if _, after, found := strings.Cut(line, ":"); found { capacityStr := strings.TrimSpace(after) capacityStr = strings.TrimSuffix(capacityStr, "%") capacity, _ = strconv.Atoi(strings.TrimSpace(capacityStr)) } } } return health, cycles, capacity } func getSystemPowerOutput() string { if runtime.GOOS != "darwin" { return "" } now := time.Now() if cachedPower != "" && now.Sub(lastPowerAt) < powerCacheTTL { return cachedPower } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() out, err := runCmd(ctx, "system_profiler", "SPPowerDataType") if err == nil { cachedPower = out lastPowerAt = now } return cachedPower } func collectThermal() ThermalStatus { if runtime.GOOS != "darwin" { return ThermalStatus{} } var thermal ThermalStatus // Fan info from cached system_profiler. out := getSystemPowerOutput() if out != "" { for line := range strings.Lines(out) { lower := strings.ToLower(line) if strings.Contains(lower, "fan") && strings.Contains(lower, "speed") { if _, after, found := strings.Cut(line, ":"); found { numStr := strings.TrimSpace(after) numStr, _, _ = strings.Cut(numStr, " ") thermal.FanSpeed, _ = strconv.Atoi(numStr) } } } } // Power metrics from ioreg (fast, real-time). ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancelPower() if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil { for line := range strings.Lines(out) { line = strings.TrimSpace(line) // Battery temperature ("Temperature" = 3055). if _, after, found := strings.Cut(line, "\"Temperature\" = "); found { valStr := strings.TrimSpace(after) if tempRaw, err := strconv.Atoi(valStr); err == nil && tempRaw > 0 { thermal.CPUTemp = float64(tempRaw) / 100.0 } } // Adapter power (Watts) from current adapter. if strings.Contains(line, "\"AdapterDetails\" = {") && !strings.Contains(line, "AppleRaw") { if _, after, found := strings.Cut(line, "\"Watts\"="); found { valStr := strings.TrimSpace(after) valStr, _, _ = strings.Cut(valStr, ",") valStr, _, _ = strings.Cut(valStr, "}") valStr = strings.TrimSpace(valStr) if watts, err := strconv.ParseFloat(valStr, 64); err == nil && watts > 0 { thermal.AdapterPower = watts } } } // System power consumption (mW -> W). if _, after, found := strings.Cut(line, "\"SystemPowerIn\"="); found { valStr := strings.TrimSpace(after) valStr, _, _ = strings.Cut(valStr, ",") valStr, _, _ = strings.Cut(valStr, "}") valStr = strings.TrimSpace(valStr) if powerMW, err := strconv.ParseFloat(valStr, 64); err == nil { // SystemPower should always be positive, reject invalid values if powerMW >= 0 && powerMW < 1000000 { // 0 to 1000W thermal.SystemPower = powerMW / 1000.0 } } } // Battery power (mW -> W, positive = discharging, negative = charging). if _, after, found := strings.Cut(line, "\"BatteryPower\"="); found { valStr := strings.TrimSpace(after) valStr, _, _ = strings.Cut(valStr, ",") valStr, _, _ = strings.Cut(valStr, "}") valStr = strings.TrimSpace(valStr) var powerMW float64 var parsed bool // Strategy 1: Try parsing as a signed integer first. // This handles standard positive values and explicit negative strings like "-12345". if valInt, err := strconv.ParseInt(valStr, 10, 64); err == nil { powerMW = float64(valInt) parsed = true } else if valUint, err := strconv.ParseUint(valStr, 10, 64); err == nil { // Strategy 2: Try parsing as an unsigned integer (Two's Complement). // ioreg often returns negative values as huge uint64 numbers (e.g. 2^64 - 100). // Casting such a uint64 to int64 correctly restores the negative value. powerMW = float64(int64(valUint)) parsed = true } if parsed { // Validate reasonable battery power range: -200W to 200W if powerMW > -200000 && powerMW < 200000 { thermal.BatteryPower = powerMW / 1000.0 } } } } } // Fallback: thermal level proxy. if thermal.CPUTemp == 0 { ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel2() out2, err := runCmd(ctx2, "sysctl", "-n", "machdep.xcpm.cpu_thermal_level") if err == nil { level, _ := strconv.Atoi(strings.TrimSpace(out2)) if level >= 0 { thermal.CPUTemp = 45 + float64(level)*0.5 } } } return thermal }