Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 135 additions & 9 deletions cmd/status/metrics_battery.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
Expand All @@ -16,10 +17,12 @@

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) {
Expand All @@ -33,7 +36,7 @@
// 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
Expand Down Expand Up @@ -121,13 +124,38 @@
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") {
Expand All @@ -142,15 +170,113 @@
}
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 value, found := parseIORegSignedNumber(line, "CycleCount"); found && value > 0 && value < 100000 {
cycles = int(value)

Check failure

Code scanning / CodeQL

Incorrect conversion between integer types High

Incorrect conversion of a signed 64-bit integer from
strconv.ParseInt
to a lower bit size type int without an upper bound check.
Incorrect conversion of an unsigned 64-bit integer from
strconv.ParseUint
to a lower bit size type int without an upper bound check.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}
}
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 ""
Expand Down
107 changes: 107 additions & 0 deletions cmd/status/metrics_battery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading