Reading Mac Battery Health From IOKit
Jake MarshIn this post
Battery health on a Mac lives in the AppleSmartBattery entry in the I/O Registry. System Information shows numbers from it, and so does the battery meter in Multimeter, which is where I ran into this.
A lot of the scripts and snippets you’ll find for it read DesignCapacity and AppleRawMaxCapacity straight off that entry and divide one by the other. On my M1 Max MacBook Pro running macOS 27.0.1, neither key is there anymore. Here’s where they went, what units everything is in, and a Swift reader that finds them.
Look Before You Code#
ioreg shows you exactly what your code will see:
ioreg -rn AppleSmartBattery -d1-n matches the entry by name, -r prints it as the root of its own subtree, and -d1 stops there so you don’t also get the pack, bank and cell entries underneath it. Count the top-level keys and you’ll find CycleCount, Voltage, Amperage, MaxCapacity and CurrentCapacity, but no DesignCapacity, no AppleRawMaxCapacity and no Temperature:
$ ioreg -rn AppleSmartBattery -d1 | grep -cE '"(DesignCapacity|AppleRawMaxCapacity|Temperature)" ='
0The capacity numbers moved into a dictionary called BatteryData:
"BatteryData" = {"FullChargeCapacity"=6804,"NominalChargeCapacity"=7048,"FullyCharged"=0,"AvgTimeToEmpty"=65535,"RemainingCapacity"=5189,"AbsoluteCapacity"=0,"MaxCapacity"=100,"DesignCapacity"=8694,"CurrentCapacity"=80,"BatteryPower"=0,"TrueRemainingCapacity"=0}That one has DesignCapacity, but it still has no temperature and no AppleRawMaxCapacity. Those are one level down, in the BatteryData of the AppleSmartBatteryPack entry that sits under AppleSmartBattery. That dictionary is much bigger (gas gauge details, lifetime minimums and maximums, the pack’s serial number), and it’s where Temperature, VirtualTemperature, AppleRawMaxCapacity and another copy of CycleCount live on this Mac.
For pulling single values out in a script, add -a to get a plist and let plutil walk the key path:
ioreg -arn AppleSmartBattery -d1 | plutil -extract 0.BatteryData.DesignCapacity raw -
# 8694
ioreg -arn AppleSmartBatteryPack -d1 | plutil -extract 0.BatteryData.Temperature raw -
# 3750The leading 0 is there because ioreg -a prints an array with one dictionary per matching entry.
MaxCapacity Is A Percentage#
The trap that’s easy to miss is that MaxCapacity and CurrentCapacity still exist at the top level, so code that reads them keeps running and quietly gets the wrong answer. On Apple silicon they’re percentages. This Mac reports MaxCapacity = 100 and CurrentCapacity = 80, which matches pmset -g batt saying 80%. Dividing MaxCapacity by DesignCapacity gives you 100 / 8694, a health of about 1%.
That pair is the right thing for the charge level (it’s what IOPSCopyPowerSourcesInfo gives you as kIOPSCurrentCapacityKey and kIOPSMaxCapacityKey). For health you want two numbers in mAh:
DesignCapacity: what the battery held new. 8694 mAh here.NominalChargeCapacity: what it holds now. 7048 mAh here.
7048 / 8694 is 81%. System Information on the same Mac says “Maximum Capacity: 80%”. AppleRawMaxCapacity (6804, the same value as FullChargeCapacity) would give 78%. Apple doesn’t document which number or rounding its own figure uses, so I use NominalChargeCapacity, since it lands closest, and fall back to AppleRawMaxCapacity when it’s missing.
Cycle count is the easy one: CycleCount is still at the top level (561 here). There’s also DesignCycleCount9C, which is 1000 on this MacBook Pro.
Temperature And Signed Numbers#
Temperature and VirtualTemperature are in hundredths of a degree Celsius, so 3750 is 37.5 °C. On this Mac both keys exist only in the pack’s BatteryData, and they read the same value.
The other unit trap is the sign. Current is negative while the battery discharges, and the text output of ioreg prints negative numbers as unsigned 64-bit values. Here’s the pack’s lifetime maximum discharge current both ways:
$ ioreg -rn AppleSmartBatteryPack -d1 | grep -o '"MaximumDischargeCurrent"=[0-9]*'
"MaximumDischargeCurrent"=18446744073709541491
$ ioreg -arn AppleSmartBatteryPack -d1 | plutil -extract 0.BatteryData.LifetimeData.MaximumDischargeCurrent raw -
-10125The plist keeps the sign, and so does Swift: NSNumber.int64Value gives you -10125. If you’re parsing the text output with grep and awk, anything near 18446744073709551616 is a negative number in disguise.
Power Draw#
There are two ways to get watts, and the better one doesn’t depend on the battery’s current. AppleSmartBattery has a PowerTelemetryData dictionary with a SystemLoad key, the whole system’s draw in milliwatts. You can check the unit against the adapter side of the same dictionary:
"SystemLoad"=27095 "SystemVoltageIn"=27777 "SystemCurrentIn"=97527777 mV x 975 mA is 27.08 W, and SystemLoad says 27095 mW. That reading was taken on the power adapter, which is the other reason to prefer it.
The fallback is Amperage (mA) times Voltage (mV), divided by a million for watts. That only works while the battery is the thing powering the Mac. Plugged in and not charging, this Mac reports Amperage = 0, and while charging the current flows into the battery, so it measures charging and tells you nothing about what the system is drawing. I count it only when the current is negative. This Mac stayed on its adapter while I wrote this, so that branch comes straight from Multimeter’s reader without a fresh reading on battery.
A Reader That Checks Every Layout#
Here’s the reader, trimmed from Multimeter and extended so it looks in all three places a key can be: the top level, AppleSmartBattery’s BatteryData and the pack’s BatteryData. It reads specific keys instead of copying the whole property table, which includes large telemetry blobs you don’t need.
import Foundation
import IOKit
func property(_ service: io_service_t, _ key: String) -> Any? {
IORegistryEntryCreateCFProperty(service, key as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue()
}
struct BatteryHealth {
var cycleCount: Int?
var health: Double? // 0...1
var temperature: Double? // °C
var watts: Double?
}
func readBatteryHealth() -> BatteryHealth? {
let battery = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery"))
guard battery != 0 else { return nil } // desktop Macs have no AppleSmartBattery
defer { IOObjectRelease(battery) }
// Where a key can live: the service itself, its BatteryData, and the
// BatteryData of its AppleSmartBatteryPack child.
var nested: [[String: Any]] = []
if let data = property(battery, "BatteryData") as? [String: Any] { nested.append(data) }
let pack = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBatteryPack"))
if pack != 0 {
if let data = property(pack, "BatteryData") as? [String: Any] { nested.append(data) }
IOObjectRelease(pack)
}
func number(_ key: String) -> NSNumber? {
if let value = property(battery, key) as? NSNumber { return value }
return nested.lazy.compactMap { $0[key] as? NSNumber }.first
}
var result = BatteryHealth()
result.cycleCount = number("CycleCount")?.intValue
// MaxCapacity is a percentage on Apple silicon, so compare mAh to mAh.
if let design = number("DesignCapacity")?.doubleValue, design > 0,
let full = (number("NominalChargeCapacity") ?? number("AppleRawMaxCapacity"))?.doubleValue,
full > 100 {
result.health = min(1, full / design)
}
// Hundredths of a degree Celsius: 3750 is 37.5 °C.
if let raw = (number("Temperature") ?? number("VirtualTemperature"))?.doubleValue, raw > 0 {
result.temperature = raw / 100
}
// Milliwatts for the whole system, when the Mac reports it.
if let telemetry = property(battery, "PowerTelemetryData") as? [String: Any],
let load = (telemetry["SystemLoad"] as? NSNumber)?.doubleValue, load > 0 {
result.watts = load / 1000
} else if let amperage = number("Amperage")?.int64Value, amperage < 0,
let voltage = number("Voltage")?.doubleValue {
// mA x mV; negative current means the battery is discharging.
result.watts = Double(-amperage) * voltage / 1_000_000
}
return result
}A few details in there are deliberate. The top level is checked first, so the same code works on a layout that still keeps everything flat. The full > 100 guard throws away a capacity that is really a percentage, so a stray MaxCapacity can never turn into a health of 1%. min(1, ...) keeps a capacity reading above the design capacity from showing up as more than 100%. And every value is optional, because a desktop Mac has no AppleSmartBattery at all and the function returns nil before reading anything.
Compiled with swiftc and run on this MacBook Pro, it prints a cycle count of 561, a health of 0.8107, a temperature of 37.5 °C and 26.588 W.
For the live charge percentage, charging state and time remaining, stay with IOPSCopyPowerSourcesInfo. Those are public API, they match pmset -g batt, and IOPSNotificationCreateRunLoopSource tells you when they change, so you only need to poll the registry for the slower numbers above.