diff --git a/cmd/status/metrics_battery.go b/cmd/status/metrics_battery.go index 4889b304..626c0222 100644 --- a/cmd/status/metrics_battery.go +++ b/cmd/status/metrics_battery.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "math" @@ -16,10 +17,12 @@ import ( var ( // Cache for heavy system_profiler output. - powerCacheMu sync.Mutex - lastPowerAt time.Time - cachedPower string - powerCacheTTL = 30 * time.Second + powerCacheMu sync.Mutex + lastPowerAt time.Time + lastPowerJSONAt time.Time + cachedPower string + cachedPowerJSON string + powerCacheTTL = 30 * time.Second ) func collectBatteries() (batts []BatteryStatus, err error) { @@ -33,7 +36,7 @@ func collectBatteries() (batts []BatteryStatus, err error) { // 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 from AppleSmartBattery and cached system_profiler. health, cycles, capacity := getCachedPowerData() if batts := parsePMSet(out, health, cycles, capacity); len(batts) > 0 { return batts, nil @@ -121,13 +124,38 @@ func parsePMSet(raw string, health string, cycles int, capacity int) []BatterySt return out } -// getCachedPowerData returns condition, cycles, and capacity from cached system_profiler. +// getCachedPowerData returns condition, cycles, and capacity from macOS power sources. func getCachedPowerData() (health string, cycles int, capacity int) { + health, cycles, capacity = getCachedSystemPowerData() + ioregCycles, ioregCapacity := getAppleSmartBatteryHealthData() + return mergeBatteryHealthData(health, cycles, capacity, ioregCycles, ioregCapacity) +} + +func getCachedSystemPowerData() (health string, cycles int, capacity int) { + if out := getSystemPowerJSONOutput(); out != "" { + if health, cycles, capacity, ok := parseSystemPowerJSON(out); ok { + return health, cycles, capacity + } + } + out := getSystemPowerOutput() if out == "" { return "", 0, 0 } + return parseSystemPowerText(out) +} + +func mergeBatteryHealthData(health string, cycles int, capacity int, ioregCycles int, ioregCapacity int) (string, int, int) { + if ioregCycles > 0 { + cycles = ioregCycles + } + if ioregCapacity > 0 { + capacity = ioregCapacity + } + return health, cycles, capacity +} +func parseSystemPowerText(out string) (health string, cycles int, capacity int) { for line := range strings.Lines(out) { lower := strings.ToLower(line) if strings.Contains(lower, "cycle count") { @@ -142,15 +170,115 @@ func getCachedPowerData() (health string, cycles int, capacity int) { } 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)) + capacity = parsePercentInt(after) } } } return health, cycles, capacity } +type systemPowerJSON struct { + SPPowerDataType []struct { + BatteryHealthInfo struct { + CycleCount int `json:"sppower_battery_cycle_count"` + Health string `json:"sppower_battery_health"` + MaximumCapacity string `json:"sppower_battery_health_maximum_capacity"` + } `json:"sppower_battery_health_info"` + } `json:"SPPowerDataType"` +} + +func parseSystemPowerJSON(raw string) (health string, cycles int, capacity int, ok bool) { + var payload systemPowerJSON + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return "", 0, 0, false + } + + for _, item := range payload.SPPowerDataType { + info := item.BatteryHealthInfo + parsedCapacity := parsePercentInt(info.MaximumCapacity) + if info.Health != "" || info.CycleCount > 0 || parsedCapacity > 0 { + return info.Health, info.CycleCount, parsedCapacity, true + } + } + return "", 0, 0, false +} + +func parsePercentInt(raw string) int { + raw = strings.TrimSpace(raw) + raw = strings.TrimSuffix(raw, "%") + raw = strings.TrimSpace(raw) + value, err := strconv.Atoi(raw) + if err != nil { + return 0 + } + return value +} + +func getAppleSmartBatteryHealthData() (cycles int, capacity int) { + if runtime.GOOS != "darwin" || !commandExists("ioreg") { + return 0, 0 + } + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + out, err := runCmd(ctx, "ioreg", "-rn", "AppleSmartBattery") + if err != nil { + return 0, 0 + } + return parseAppleSmartBatteryHealth(out) +} + +func parseAppleSmartBatteryHealth(out string) (cycles int, capacity int) { + for line := range strings.Lines(out) { + line = strings.TrimSpace(line) + if cycles == 0 { + if raw, found := ioRegValueForKey(line, "CycleCount"); found { + if value, err := strconv.Atoi(raw); err == nil && value > 0 && value < 100000 { + cycles = value + } + } + } + if capacity == 0 { + if value, found := parseIORegFloatValue(line, "MaxCapacity"); found { + capacity = normalizeBatteryHealthCapacity(value) + } + } + } + return cycles, capacity +} + +func normalizeBatteryHealthCapacity(value float64) int { + if value <= 0 || value > 100 { + return 0 + } + return int(math.Round(value)) +} + +func getSystemPowerJSONOutput() string { + if runtime.GOOS != "darwin" { + return "" + } + + powerCacheMu.Lock() + defer powerCacheMu.Unlock() + + now := time.Now() + if cachedPowerJSON != "" && now.Sub(lastPowerJSONAt) < powerCacheTTL { + return cachedPowerJSON + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + out, err := runCmd(ctx, "system_profiler", "SPPowerDataType", "-json") + if err == nil { + cachedPowerJSON = out + lastPowerJSONAt = now + } + return cachedPowerJSON +} + func getSystemPowerOutput() string { if runtime.GOOS != "darwin" { return "" diff --git a/cmd/status/metrics_battery_test.go b/cmd/status/metrics_battery_test.go index 8bc3bb47..e2b71f84 100644 --- a/cmd/status/metrics_battery_test.go +++ b/cmd/status/metrics_battery_test.go @@ -5,6 +5,113 @@ import ( "testing" ) +func TestParseSystemPowerJSON(t *testing.T) { + raw := `{ + "SPPowerDataType" : [ + { + "sppower_battery_health_info" : { + "sppower_battery_cycle_count" : 4, + "sppower_battery_health" : "Good", + "sppower_battery_health_maximum_capacity" : "100\u00a0%" + } + } + ] +}` + + health, cycles, capacity, ok := parseSystemPowerJSON(raw) + + if !ok { + t.Fatal("expected battery health JSON to parse") + } + if health != "Good" { + t.Fatalf("health = %q, want Good", health) + } + if cycles != 4 { + t.Fatalf("cycles = %d, want 4", cycles) + } + if capacity != 100 { + t.Fatalf("capacity = %d, want 100", capacity) + } +} + +func TestParseSystemPowerJSONRejectsInvalidPayload(t *testing.T) { + _, _, _, ok := parseSystemPowerJSON(`{"SPPowerDataType":[{}]}`) + if ok { + t.Fatal("expected empty battery health JSON to be ignored") + } +} + +func TestParseSystemPowerText(t *testing.T) { + nonBreakingSpace := string(rune(0x00a0)) + raw := " Battery Information:\n\n" + + " Health Information:\n" + + " Cycle Count: 12\n" + + " Condition: Normal\n" + + " Maximum Capacity: 97" + nonBreakingSpace + "%\n" + + health, cycles, capacity := parseSystemPowerText(raw) + + if health != "Normal" { + t.Fatalf("health = %q, want Normal", health) + } + if cycles != 12 { + t.Fatalf("cycles = %d, want 12", cycles) + } + if capacity != 97 { + t.Fatalf("capacity = %d, want 97", capacity) + } +} + +func TestMergeBatteryHealthDataPrefersAppleSmartBatteryCapacity(t *testing.T) { + health, cycles, capacity := mergeBatteryHealthData("Good", 12, 97, 13, 100) + + if health != "Good" { + t.Fatalf("health = %q, want Good", health) + } + if cycles != 13 { + t.Fatalf("cycles = %d, want 13", cycles) + } + if capacity != 100 { + t.Fatalf("capacity = %d, want 100", capacity) + } +} + +func TestParseAppleSmartBatteryHealth(t *testing.T) { + out := ` + | | "BatteryData" = {"MaxCapacity"=100,"DesignCapacity"=8579,"BatteryHealthMetric"=0} + | | "NominalChargeCapacity" = 7989 + | | "MaxCapacity" = 100 + | | "DesignCapacity" = 8579 + | | "CycleCount" = 4 +` + + cycles, capacity := parseAppleSmartBatteryHealth(out) + + if cycles != 4 { + t.Fatalf("cycles = %d, want 4", cycles) + } + if capacity != 100 { + t.Fatalf("capacity = %d, want 100", capacity) + } +} + +func TestParseAppleSmartBatteryHealthIgnoresRawMaxCapacity(t *testing.T) { + out := ` + | | "MaxCapacity" = 7745 + | | "DesignCapacity" = 8579 + | | "CycleCount" = 12 +` + + cycles, capacity := parseAppleSmartBatteryHealth(out) + + if cycles != 12 { + t.Fatalf("cycles = %d, want 12", cycles) + } + if capacity != 0 { + t.Fatalf("capacity = %d, want 0", capacity) + } +} + func TestParseAppleSmartBatteryThermalKeepsBatteryTemperatureOutOfCPUTemp(t *testing.T) { out := ` | | "Temperature" = 3055