Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

2536 lines
86 KiB
Swift

import Foundation
import CoreBluetooth
import IOBluetooth
import IOKit
import IOKit.ps
import MacToolsPluginKit
protocol DeviceBatterySampling: Sendable {
func collectSystemDevices(
referenceDate: Date,
options: DeviceBatterySamplingOptions
) async -> [DeviceBatteryItem]
}
struct DeviceBatterySamplingOptions: Equatable, Sendable {
let includeInternalBattery: Bool
let includeBluetoothDevices: Bool
let includeAppleMobileDevices: Bool
let appleMobileRefreshInterval: TimeInterval
}
struct DeviceBatterySampler: DeviceBatterySampling {
private static let bluetoothPowerLogLookback = "1m"
private static let bluetoothPowerLogTimeout: TimeInterval = 1.5
private static let batteryCenterLogLookback = "2m"
private static let batteryCenterLogTimeout: TimeInterval = 1.0
private let localization: PluginLocalization
private let mobileDeviceReader: any DeviceBatteryMobileDeviceSampling
init(
localization: PluginLocalization = PluginLocalization(bundle: .main),
mobileDeviceReader: any DeviceBatteryMobileDeviceSampling = DeviceBatteryMobileDeviceReader()
) {
self.localization = localization
self.mobileDeviceReader = mobileDeviceReader
}
func collectSystemDevices(
referenceDate: Date,
options: DeviceBatterySamplingOptions
) async -> [DeviceBatteryItem] {
let localization = localization
let baseSample = await Task.detached(priority: .utility) {
var items: [DeviceBatteryItem] = []
if options.includeInternalBattery {
items.append(contentsOf: Self.collectInternalBattery(
referenceDate: referenceDate,
localization: localization
))
}
let bluetoothData = options.includeBluetoothDevices
? Self.collectBluetoothProfile()
: BluetoothProfile(connectedDevices: [], batteryDevices: [])
if options.includeBluetoothDevices {
items.append(contentsOf: Self.collectBluetoothDevices(
from: bluetoothData,
referenceDate: referenceDate,
localization: localization
))
items.append(contentsOf: Self.collectMagicAccessoryDevices(
from: bluetoothData,
referenceDate: referenceDate,
localization: localization
))
}
let targets = options.includeBluetoothDevices
? Self.bluetoothBatteryTargets(from: bluetoothData)
: []
return DeviceBatteryBaseSample(
items: Self.deduplicated(items),
bluetoothBatteryTargets: targets,
shouldReadBatteryCenterLog: !targets.isEmpty
|| !bluetoothData.connectedDevices.isEmpty
|| !bluetoothData.batteryDevices.isEmpty
)
}.value
async let mobileDeviceItems = options.includeAppleMobileDevices
? mobileDeviceReader.collectDevices(
referenceDate: referenceDate,
minimumRefreshInterval: options.appleMobileRefreshInterval
)
: []
async let bluetoothPowerLogItems = Task.detached(priority: .utility) {
Self.collectBluetoothPowerLogDevices(
targets: baseSample.bluetoothBatteryTargets,
existingItems: baseSample.items,
referenceDate: referenceDate,
localization: localization
)
}.value
async let batteryCenterLogItems = Task.detached(priority: .utility) {
Self.collectBatteryCenterLogDevices(
shouldReadLog: baseSample.shouldReadBatteryCenterLog,
targets: baseSample.bluetoothBatteryTargets,
referenceDate: referenceDate
)
}.value
async let appleHeadphoneAdvertisementItems = DeviceBatteryAppleHeadphoneAdvertisementReader.collectBatteryDevices(
targets: baseSample.bluetoothBatteryTargets,
referenceDate: referenceDate,
localization: localization
)
async let bluetoothBatteryItems = DeviceBatteryBLEBatteryReader.collectBatteryDevices(
targets: baseSample.bluetoothBatteryTargets,
referenceDate: referenceDate,
localization: localization
)
let powerLogItems = await bluetoothPowerLogItems
let batteryLogItems = await batteryCenterLogItems
let advertisementItems = await appleHeadphoneAdvertisementItems
let bleItems = await bluetoothBatteryItems
let mobileItems = await mobileDeviceItems
return Self.deduplicated(
baseSample.items
+ mobileItems
+ powerLogItems
+ batteryLogItems
+ advertisementItems
+ bleItems
)
}
private static func collectInternalBattery(
referenceDate: Date,
localization: PluginLocalization
) -> [DeviceBatteryItem] {
guard
let powerSourcesInfo = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
let powerSources = IOPSCopyPowerSourcesList(powerSourcesInfo)?.takeRetainedValue() as? [CFTypeRef],
!powerSources.isEmpty
else {
return []
}
var fallbackDescription: [String: Any]?
var batteryDescription: [String: Any]?
for source in powerSources {
guard let description = IOPSGetPowerSourceDescription(powerSourcesInfo, source)?.takeUnretainedValue() as? [String: Any] else {
continue
}
fallbackDescription = fallbackDescription ?? description
if description[kIOPSTypeKey] as? String == kIOPSInternalBatteryType {
batteryDescription = description
break
}
}
guard let description = batteryDescription ?? fallbackDescription else {
return []
}
let maxCapacity = max(description[kIOPSMaxCapacityKey] as? Int ?? 100, 1)
let currentCapacity = min(max(description[kIOPSCurrentCapacityKey] as? Int ?? 0, 0), maxCapacity)
let level = Int((Double(currentCapacity) / Double(maxCapacity) * 100).rounded())
let isCharging = description[kIOPSIsChargingKey] as? Bool ?? false
let isCharged = (description[kIOPSIsChargedKey] as? Bool) ?? (level >= 100)
let powerSource = description[kIOPSPowerSourceStateKey] as? String ?? ""
let chargeState = batteryChargeState(
level: level,
isCharging: isCharging,
isCharged: isCharged,
powerSource: powerSource
)
let name = internalBatteryDisplayName(
rawName: description[kIOPSNameKey] as? String,
localization: localization
)
return [
DeviceBatteryItem(
id: "internal-battery",
name: name,
model: nil,
kind: .internalBattery,
level: level,
chargeState: chargeState,
parentName: nil,
source: "IOPowerSources",
lastUpdated: referenceDate,
isConnected: true,
detail: internalBatteryDetail(
description: description,
chargeState: chargeState,
localization: localization
),
componentIdentity: nil
)
]
}
private static func batteryChargeState(
level: Int,
isCharging: Bool,
isCharged: Bool,
powerSource: String
) -> DeviceBatteryChargeState {
if isCharged || level >= 100 {
return .charged
}
if isCharging {
return .charging
}
if powerSource == "AC Power" {
return .plugged
}
return .normal
}
private static func internalBatteryDetail(
description: [String: Any],
chargeState: DeviceBatteryChargeState,
localization: PluginLocalization
) -> String {
let timeKey = chargeState == .charging ? kIOPSTimeToFullChargeKey : kIOPSTimeToEmptyKey
guard let minutes = description[timeKey] as? Int, minutes > 0 else {
return chargeState.title(localization: localization)
}
return localization.format(
"batteryDetail.remainingTime",
defaultValue: "%@ %d小时%d分",
chargeState.title(localization: localization),
minutes / 60,
minutes % 60
)
}
private static func internalBatteryDisplayName(
rawName: String?,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) -> String {
let cleanedName = rawName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let lowercasedName = cleanedName.lowercased()
let isSystemIdentifier = cleanedName.isEmpty
|| lowercasedName.contains("internal")
|| lowercasedName == "battery"
|| lowercasedName.contains("battery-")
if !isSystemIdentifier {
return cleanedName
}
let hostName = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return hostName.isEmpty
? localization.string("deviceName.internalBattery", defaultValue: "Mac 电池")
: hostName
}
private static func collectBluetoothProfile() -> BluetoothProfile {
guard let output = runCommand(
path: "/usr/sbin/system_profiler",
arguments: ["SPBluetoothDataType", "-json"],
timeout: 3
) else {
return BluetoothProfile(connectedDevices: [], batteryDevices: [])
}
return bluetoothProfile(fromSystemProfilerOutput: output)
}
private static func bluetoothProfile(
fromSystemProfilerOutput output: String
) -> BluetoothProfile {
guard
let data = output.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let rawSections = json["SPBluetoothDataType"] as? [[String: Any]],
let section = rawSections.first
else {
return BluetoothProfile(connectedDevices: [], batteryDevices: [])
}
let connectedDevices = parseBluetoothDevices(
from: section["device_connected"],
isConnected: true
)
let disconnectedBatteryDevices = parseBluetoothDevices(
from: section["device_not_connected"],
isConnected: false
)
.filter { hasBluetoothBatteryFields($0.info) || isAppleHeadphoneBatteryCandidate($0) }
return BluetoothProfile(
connectedDevices: connectedDevices,
batteryDevices: mergedBatteryDevices(
connectedDevices + disconnectedBatteryDevices
)
)
}
static func bluetoothProfileBatteryItems(
fromSystemProfilerOutput output: String,
referenceDate: Date
) -> [DeviceBatteryItem] {
collectBluetoothProfileBatteryItems(
from: bluetoothProfile(fromSystemProfilerOutput: output),
referenceDate: referenceDate
)
}
private static func parseBluetoothDevices(
from value: Any?,
isConnected: Bool
) -> [BluetoothProfileDevice] {
guard let rawDevices = value as? [[String: Any]] else {
return []
}
return rawDevices.compactMap { rawDevice in
guard let name = rawDevice.keys.first,
let info = rawDevice[name] as? [String: Any]
else {
return nil
}
return BluetoothProfileDevice(
name: name,
info: info,
isConnected: isConnected
)
}
}
private static func hasBluetoothBatteryFields(_ info: [String: Any]) -> Bool {
[
"device_batteryLevelMain",
"device_batteryLevelCase",
"device_batteryLevelLeft",
"device_batteryLevelRight"
]
.contains { batteryValue(from: info[$0]) != nil }
}
private static func isAppleHeadphoneBatteryCandidate(_ device: BluetoothProfileDevice) -> Bool {
guard normalizedHexIdentifier(stringValue(device.info["device_vendorID"])) == "004C" else {
return false
}
let productID = stringValue(device.info["device_productID"])
let model = productID.flatMap { HeadphoneModelCatalog.modelName(forProductID: $0) }
let haystack = [
device.name,
model,
device.info["device_minorType"] as? String
]
.compactMap { $0?.lowercased() }
.joined(separator: " ")
return haystack.contains("airpods")
|| haystack.contains("beats")
|| haystack.contains("headphone")
|| haystack.contains("headset")
}
private static func mergedBatteryDevices(_ devices: [BluetoothProfileDevice]) -> [BluetoothProfileDevice] {
var mergedByKey: [String: BluetoothProfileDevice] = [:]
var orderedKeys: [String] = []
for device in devices {
let key = normalizedIdentifier(device.info["device_address"])
?? normalizedDeviceName(device.name)
if let existing = mergedByKey[key] {
if !existing.isConnected, device.isConnected {
mergedByKey[key] = device
}
} else {
mergedByKey[key] = device
orderedKeys.append(key)
}
}
return orderedKeys.compactMap { mergedByKey[$0] }
}
private static func collectBluetoothPowerLogDevices(
targets allTargets: [BluetoothBatteryTarget],
existingItems: [DeviceBatteryItem],
referenceDate: Date,
localization: PluginLocalization
) -> [DeviceBatteryItem] {
let targets = allTargets.filter { target in
needsBluetoothPowerLogFallback(target: target, existingItems: existingItems)
}
guard !targets.isEmpty else {
return []
}
let componentTargets = targets.filter { supportsComponentPowerLog(target: $0) }
let regularTargets = targets.filter { !supportsComponentPowerLog(target: $0) }
let recentReadings = collectBluetoothPowerLogReadings(
targets: targets,
lookback: bluetoothPowerLogLookback,
timeout: bluetoothPowerLogTimeout
)
var items: [DeviceBatteryItem] = []
if !componentTargets.isEmpty {
let componentReadings = recentReadings.filter { reading in
matchingBluetoothPowerLogTarget(reading, in: componentTargets) != nil
}
items.append(contentsOf: bluetoothPowerLogItems(
from: componentReadings,
targets: componentTargets,
referenceDate: referenceDate,
localization: localization
))
}
if !regularTargets.isEmpty {
let regularReadings = recentReadings.filter { reading in
matchingBluetoothPowerLogTarget(reading, in: regularTargets) != nil
}
let regularItems = bluetoothPowerLogItems(
from: regularReadings,
targets: regularTargets,
referenceDate: referenceDate,
localization: localization
)
if !regularItems.isEmpty {
items.append(contentsOf: regularItems)
}
}
return DeviceBatteryItemNormalizer.removingRedundantComponentAggregates(items)
}
private static let bluetoothPowerLogPredicate = #"subsystem == "com.apple.bluetooth" AND category == "CBPowerSource" AND eventMessage CONTAINS "Battery""#
private static func collectBluetoothPowerLogReadings(
targets: [BluetoothBatteryTarget],
lookback: String,
timeout: TimeInterval
) -> [DeviceBatteryBluetoothPowerLogReading] {
guard let output = runCommand(
path: "/usr/bin/nice",
arguments: [
"-n",
"19",
"/usr/bin/log",
"show",
"--process",
"bluetoothd",
"--info",
"--last",
lookback,
"--style",
"compact",
"--predicate",
bluetoothPowerLogPredicate(for: targets)
],
timeout: timeout,
outputLineFilter: { line in
isBluetoothPowerLogLine(line, matching: targets)
}
) else {
return []
}
return DeviceBatteryBluetoothPowerLogParser.readings(from: output)
}
private static func collectBatteryCenterLogDevices(
shouldReadLog: Bool,
targets: [BluetoothBatteryTarget],
referenceDate: Date
) -> [DeviceBatteryItem] {
guard shouldReadLog else {
return []
}
let recentReadings = collectBatteryCenterLogReadings(
lookback: batteryCenterLogLookback,
timeout: batteryCenterLogTimeout
)
return batteryCenterLogItems(
from: recentReadings,
targets: targets,
referenceDate: referenceDate
)
}
private static func collectBatteryCenterLogReadings(
lookback: String,
timeout: TimeInterval
) -> [DeviceBatteryBatteryCenterLogReading] {
guard let output = runCommand(
path: "/usr/bin/nice",
arguments: [
"-n",
"19",
"/usr/bin/log",
"show",
"--info",
"--last",
lookback,
"--style",
"compact",
"--predicate",
#"subsystem == "com.apple.BatteryCenter" AND eventMessage CONTAINS "BCBatteryDevice" AND eventMessage CONTAINS "percentCharge""#
],
timeout: timeout,
outputLineFilter: { line in
line.contains("BCBatteryDevice") && line.contains("percentCharge")
}
) else {
return []
}
return DeviceBatteryBatteryCenterLogParser.readings(from: output)
}
private static func batteryCenterLogItems(
from readings: [DeviceBatteryBatteryCenterLogReading],
targets: [BluetoothBatteryTarget],
referenceDate: Date
) -> [DeviceBatteryItem] {
readings.compactMap { reading in
if let target = matchingBatteryCenterLogTarget(reading, in: targets) {
return DeviceBatteryItem(
id: "batterycenter-\(target.address ?? target.id)",
name: target.name,
model: firstNonEmpty(reading.model, target.model),
kind: target.kind,
level: reading.level,
chargeState: reading.chargeState,
parentName: nil,
source: "BatteryCenter",
lastUpdated: referenceDate,
isConnected: reading.isConnected ?? target.isConnected,
detail: firstNonEmpty(reading.category, target.detail),
componentIdentity: componentAggregateIdentity(
groupID: target.componentGroupID,
kind: target.kind
)
)
}
guard reading.isInternal != true,
let name = firstNonEmptyOptional(reading.name, reading.groupName)
else {
return nil
}
let kind = batteryCenterKind(reading: reading)
return DeviceBatteryItem(
id: "batterycenter-\(reading.accessoryID ?? normalizedDeviceName(name))",
name: name,
model: reading.model,
kind: kind,
level: reading.level,
chargeState: reading.chargeState,
parentName: nil,
source: "BatteryCenter",
lastUpdated: referenceDate,
isConnected: reading.isConnected ?? true,
detail: firstNonEmpty(reading.category, reading.transportType),
componentIdentity: componentAggregateIdentity(
groupID: reading.accessoryID ?? normalizedDeviceName(name),
kind: kind
)
)
}
}
private static func batteryCenterKind(reading: DeviceBatteryBatteryCenterLogReading) -> DeviceBatteryKind {
let haystack = [
reading.name,
reading.groupName,
reading.model,
reading.category
]
.compactMap { $0?.lowercased() }
.joined(separator: " ")
if haystack.contains("iphone") || haystack.contains("mobilephone") || haystack.contains("phone") {
return .phone
}
if haystack.contains("ipad") || haystack.contains("tablet") {
return .tablet
}
if haystack.contains("ipod") {
return .mediaPlayer
}
if haystack.contains("watch") {
return .watch
}
if haystack.contains("vision") || haystack.contains("realitydevice") {
return .spatialComputer
}
if haystack.contains("airpods")
|| haystack.contains("beats")
|| haystack.contains("headphone")
|| haystack.contains("headset") {
return .airPodsPart
}
if haystack.contains("magic")
|| haystack.contains("keyboard")
|| haystack.contains("mouse")
|| haystack.contains("trackpad") {
return .magicAccessory
}
return .bluetooth
}
private static func firstNonEmptyOptional(_ values: String?...) -> String? {
for value in values {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty {
return trimmed
}
}
return nil
}
fileprivate static func canUseUnmatchedBatteryCenterReading(
_ reading: DeviceBatteryBatteryCenterLogReading
) -> Bool {
reading.isInternal != true && firstNonEmptyOptional(reading.name, reading.groupName) != nil
}
fileprivate static func matchingBatteryCenterLogTarget(
_ reading: DeviceBatteryBatteryCenterLogReading,
in targets: [BluetoothBatteryTarget]
) -> BluetoothBatteryTarget? {
let nameMatchedTargets = targets.filter { target in
batteryCenterNamesMatch(reading: reading, target: target)
&& batteryCenterProductIdentifiersMatch(reading: reading, target: target)
}
if nameMatchedTargets.count == 1 {
return nameMatchedTargets[0]
}
guard let readingProductID = normalizedProductIdentifier(reading.productID) else {
return nil
}
let productMatchedTargets = targets.filter { target in
normalizedProductIdentifier(target.productID) == readingProductID
}
return productMatchedTargets.count == 1 ? productMatchedTargets[0] : nil
}
private static func batteryCenterNamesMatch(
reading: DeviceBatteryBatteryCenterLogReading,
target: BluetoothBatteryTarget
) -> Bool {
let readingNames = [reading.name, reading.groupName]
.compactMap { $0 }
.map(normalizedDeviceName)
return readingNames.contains(normalizedDeviceName(target.name))
}
private static func batteryCenterProductIdentifiersMatch(
reading: DeviceBatteryBatteryCenterLogReading,
target: BluetoothBatteryTarget
) -> Bool {
guard let readingProductID = normalizedProductIdentifier(reading.productID),
let targetProductID = normalizedProductIdentifier(target.productID)
else {
return true
}
return readingProductID == targetProductID
}
private static func bluetoothPowerLogPredicate(for targets: [BluetoothBatteryTarget]) -> String {
let targetPredicates = targets.flatMap { target -> [String] in
var predicates: [String] = []
let name = target.name.trimmingCharacters(in: .whitespacesAndNewlines)
if !name.isEmpty {
predicates.append(#"eventMessage CONTAINS[c] "\#(escapedPredicateString(name))""#)
}
let vendorID = target.vendorID?.trimmingCharacters(in: .whitespacesAndNewlines)
let productID = target.productID?.trimmingCharacters(in: .whitespacesAndNewlines)
if let vendorID, !vendorID.isEmpty, let productID, !productID.isEmpty {
predicates.append(
#"(eventMessage CONTAINS "\#(escapedPredicateString(vendorID))" AND eventMessage CONTAINS "\#(escapedPredicateString(productID))")"#
)
} else if let productID, !productID.isEmpty {
predicates.append(#"eventMessage CONTAINS "\#(escapedPredicateString(productID))""#)
}
return predicates
}
guard !targetPredicates.isEmpty else {
return bluetoothPowerLogPredicate
}
return "\(bluetoothPowerLogPredicate) AND (\(targetPredicates.joined(separator: " OR ")))"
}
private static func escapedPredicateString(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
}
private static func isBluetoothPowerLogLine(
_ line: String,
matching targets: [BluetoothBatteryTarget]
) -> Bool {
guard line.contains("CBPowerSource"),
line.contains("Power source updated"),
line.contains("Battery")
else {
return false
}
return targets.contains { target in
bluetoothPowerLogLine(line, matches: target)
}
}
static func isBluetoothPowerLogLine(
_ line: String,
matchingName name: String,
vendorID: String?,
productID: String?
) -> Bool {
guard line.contains("CBPowerSource"),
line.contains("Power source updated"),
line.contains("Battery")
else {
return false
}
if line.localizedCaseInsensitiveContains(name) {
return true
}
let normalizedLine = line.uppercased()
if let vendorID = normalizedHexIdentifier(vendorID),
let productID = normalizedHexIdentifier(productID),
normalizedLine.contains("0X\(vendorID)"),
normalizedLine.contains("0X\(productID)") {
return true
}
return false
}
private static func bluetoothPowerLogLine(
_ line: String,
matches target: BluetoothBatteryTarget
) -> Bool {
isBluetoothPowerLogLine(
line,
matchingName: target.name,
vendorID: target.vendorID,
productID: target.productID
)
}
private static func bluetoothPowerLogItems(
from readings: [DeviceBatteryBluetoothPowerLogReading],
targets: [BluetoothBatteryTarget],
referenceDate: Date,
localization: PluginLocalization
) -> [DeviceBatteryItem] {
readings.compactMap { reading in
guard let target = matchingBluetoothPowerLogTarget(reading, in: targets) else {
return nil
}
return DeviceBatteryItem(
id: powerLogItemID(reading: reading, target: target),
name: powerLogItemName(reading: reading, target: target, localization: localization),
model: target.model,
kind: reading.component == nil ? target.kind : .airPodsPart,
level: reading.level,
chargeState: reading.chargeState,
parentName: powerLogParentName(reading: reading, target: target, localization: localization),
source: "BluetoothPowerLog",
lastUpdated: referenceDate,
isConnected: target.isConnected,
detail: reading.deviceType ?? target.detail,
componentIdentity: powerLogComponentIdentity(reading: reading, target: target)
)
}
}
private static func needsBluetoothPowerLogFallback(
target: BluetoothBatteryTarget,
existingItems: [DeviceBatteryItem]
) -> Bool {
if supportsComponentPowerLog(target: target) {
return true
}
guard target.isConnected else {
return false
}
if normalizedHexIdentifier(target.vendorID) == "004C" {
return false
}
let targetName = normalizedDeviceName(target.name)
return !existingItems.contains { item in
item.parentName == nil
&& normalizedDeviceName(item.name) == targetName
&& item.clampedLevel != nil
}
}
private static func powerLogItemName(
reading: DeviceBatteryBluetoothPowerLogReading,
target: BluetoothBatteryTarget,
localization: PluginLocalization
) -> String {
guard let component = reading.component else {
return target.name
}
return localization.format(
"deviceName.withPart",
defaultValue: "%@ %@",
target.name,
component.title(localization: localization)
)
}
private static func powerLogItemID(
reading: DeviceBatteryBluetoothPowerLogReading,
target: BluetoothBatteryTarget
) -> String {
let baseID = target.address ?? target.id
guard let component = reading.component else {
return "bluetooth-powerlog-\(baseID)"
}
return "bluetooth-powerlog-\(baseID)-\(component.idSuffix)"
}
private static func powerLogParentName(
reading: DeviceBatteryBluetoothPowerLogReading,
target: BluetoothBatteryTarget,
localization: PluginLocalization
) -> String? {
switch reading.component {
case nil:
return nil
case .chargingCase:
return target.name
case .left, .right:
return localization.format(
"deviceName.withPart",
defaultValue: "%@ %@",
target.name,
DeviceBatteryBluetoothPowerLogComponent.chargingCase.title(localization: localization)
)
}
}
fileprivate static func powerLogItemNameForReader(
component: DeviceBatteryBluetoothPowerLogComponent,
targetName: String,
localization: PluginLocalization
) -> String {
localization.format(
"deviceName.withPart",
defaultValue: "%@ %@",
targetName,
component.title(localization: localization)
)
}
fileprivate static func powerLogParentNameForReader(
component: DeviceBatteryBluetoothPowerLogComponent,
targetName: String,
localization: PluginLocalization
) -> String? {
switch component {
case .chargingCase:
return targetName
case .left, .right:
return localization.format(
"deviceName.withPart",
defaultValue: "%@ %@",
targetName,
DeviceBatteryBluetoothPowerLogComponent.chargingCase.title(localization: localization)
)
}
}
private static func powerLogComponentIdentity(
reading: DeviceBatteryBluetoothPowerLogReading,
target: BluetoothBatteryTarget
) -> DeviceBatteryComponentIdentity? {
guard supportsComponentPowerLog(target: target) || reading.component != nil else {
return nil
}
return DeviceBatteryComponentIdentity(
groupID: target.componentGroupID,
role: reading.component?.componentRole ?? .aggregate
)
}
private static func supportsComponentPowerLog(target: BluetoothBatteryTarget) -> Bool {
guard normalizedHexIdentifier(target.vendorID) == "004C" else {
return false
}
let haystack = [
target.name,
target.model,
target.detail
]
.compactMap { $0?.lowercased() }
.joined(separator: " ")
return target.kind == .airPodsPart
|| haystack.contains("airpods")
|| haystack.contains("beats")
|| haystack.contains("headphone")
|| haystack.contains("headset")
}
private static func collectBluetoothDevices(
from profile: BluetoothProfile,
referenceDate: Date,
localization: PluginLocalization
) -> [DeviceBatteryItem] {
var items = collectBluetoothProfileBatteryItems(
from: profile,
referenceDate: referenceDate,
localization: localization
)
items.append(contentsOf: collectIOBluetoothBattery(from: profile, referenceDate: referenceDate))
return items
}
private static func collectBluetoothProfileBatteryItems(
from profile: BluetoothProfile,
referenceDate: Date,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) -> [DeviceBatteryItem] {
var items: [DeviceBatteryItem] = []
for device in profile.batteryDevices {
let productID = stringValue(device.info["device_productID"])
let model = productID.flatMap { HeadphoneModelCatalog.modelName(forProductID: $0) }
let parentID = normalizedIdentifier(device.info["device_address"]) ?? normalizedIdentifier(productID) ?? device.name
appendBluetoothLevel(
to: &items,
name: device.name,
suffix: nil,
fieldName: "device_batteryLevelMain",
device: device,
id: parentID,
kind: inferredKind(device: device, field: "main"),
model: model,
parentName: nil,
componentRole: hasBluetoothComponentBatteryFields(device.info) ? .aggregate : nil,
referenceDate: referenceDate
)
let caseName = bluetoothPartName(
deviceName: device.name,
component: .chargingCase,
localization: localization
)
appendBluetoothLevel(
to: &items,
name: caseName,
suffix: "case",
fieldName: "device_batteryLevelCase",
device: device,
id: parentID,
kind: .airPodsPart,
model: model,
parentName: device.name,
componentRole: .chargingCase,
referenceDate: referenceDate
)
appendBluetoothLevel(
to: &items,
name: bluetoothPartName(deviceName: device.name, component: .left, localization: localization),
suffix: "left",
fieldName: "device_batteryLevelLeft",
device: device,
id: parentID,
kind: .airPodsPart,
model: model,
parentName: caseName,
componentRole: .left,
referenceDate: referenceDate
)
appendBluetoothLevel(
to: &items,
name: bluetoothPartName(deviceName: device.name, component: .right, localization: localization),
suffix: "right",
fieldName: "device_batteryLevelRight",
device: device,
id: parentID,
kind: .airPodsPart,
model: model,
parentName: caseName,
componentRole: .right,
referenceDate: referenceDate
)
}
return DeviceBatteryItemNormalizer.removingRedundantComponentAggregates(items)
}
private static func bluetoothPartName(
deviceName: String,
component: DeviceBatteryBluetoothPowerLogComponent,
localization: PluginLocalization
) -> String {
localization.format(
"deviceName.withPart",
defaultValue: "%@ %@",
deviceName,
component.title(localization: localization)
)
}
private static func appendBluetoothLevel(
to items: inout [DeviceBatteryItem],
name: String,
suffix: String?,
fieldName: String,
device: BluetoothProfileDevice,
id: String,
kind: DeviceBatteryKind,
model: String?,
parentName: String?,
componentRole: DeviceBatteryComponentRole?,
referenceDate: Date
) {
guard let batteryValue = batteryValue(from: device.info[fieldName]) else {
return
}
let itemID = suffix.map { "\(id)-\($0)" } ?? id
items.append(
DeviceBatteryItem(
id: "bluetooth-\(itemID)",
name: name,
model: model,
kind: kind,
level: batteryValue.level,
chargeState: batteryValue.chargeState,
parentName: parentName,
source: "system_profiler",
lastUpdated: referenceDate,
isConnected: device.isConnected,
detail: device.info["device_minorType"] as? String,
componentIdentity: componentRole.map {
DeviceBatteryComponentIdentity(groupID: id, role: $0)
}
)
)
}
private static func hasBluetoothComponentBatteryFields(_ info: [String: Any]) -> Bool {
[
"device_batteryLevelCase",
"device_batteryLevelLeft",
"device_batteryLevelRight"
]
.contains { batteryValue(from: info[$0]) != nil }
}
private static func collectIOBluetoothBattery(
from profile: BluetoothProfile,
referenceDate: Date
) -> [DeviceBatteryItem] {
guard let devices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
return []
}
return devices.compactMap { device in
guard device.isConnected(),
let level = device.pluginBatteryPercentSingle,
level > 0,
let name = device.name,
!name.isEmpty
else {
return nil
}
let address = normalizedIdentifier(device.addressString) ?? name
let profileDevice = profile.connectedDevices.first { normalizedIdentifier($0.info["device_address"]) == address }
let kind = profileDevice.map { inferredKind(device: $0, field: "single") } ?? .bluetooth
return DeviceBatteryItem(
id: "iobluetooth-\(address)",
name: name,
model: nil,
kind: kind,
level: level,
chargeState: .normal,
parentName: nil,
source: "IOBluetooth",
lastUpdated: referenceDate,
isConnected: true,
detail: profileDevice?.info["device_minorType"] as? String,
componentIdentity: componentAggregateIdentity(groupID: address, kind: kind)
)
}
}
private static func collectMagicAccessoryDevices(
from profile: BluetoothProfile,
referenceDate: Date,
localization: PluginLocalization
) -> [DeviceBatteryItem] {
let classes = [
"AppleDeviceManagementHIDEventService",
"AppleBluetoothHIDKeyboard",
"BNBTrackpadDevice",
"BNBMouseDevice"
]
return classes.flatMap { serviceClass in
collectIORegistryBatteryDevices(
matchingService: serviceClass,
profile: profile,
referenceDate: referenceDate,
localization: localization
)
}
}
private static func collectIORegistryBatteryDevices(
matchingService serviceClass: String,
profile: BluetoothProfile,
referenceDate: Date,
localization: PluginLocalization
) -> [DeviceBatteryItem] {
var iterator = io_iterator_t()
let result = IOServiceGetMatchingServices(
kIOMainPortDefault,
IOServiceMatching(serviceClass),
&iterator
)
guard result == KERN_SUCCESS else {
return []
}
defer { IOObjectRelease(iterator) }
var items: [DeviceBatteryItem] = []
while true {
let object = IOIteratorNext(iterator)
guard object != 0 else {
break
}
defer { IOObjectRelease(object) }
guard let item = makeIORegistryBatteryItem(
object: object,
serviceClass: serviceClass,
profile: profile,
referenceDate: referenceDate,
localization: localization
) else {
continue
}
items.append(item)
}
return items
}
private static func makeIORegistryBatteryItem(
object: io_object_t,
serviceClass: String,
profile: BluetoothProfile,
referenceDate: Date,
localization: PluginLocalization
) -> DeviceBatteryItem? {
guard let level = intProperty("BatteryPercent", object: object),
(0...100).contains(level)
else {
return nil
}
let rawAddress = stringProperty("DeviceAddress", object: object)
let address = normalizedIdentifier(rawAddress)
let productName = stringProperty("Product", object: object)
guard productName?.localizedCaseInsensitiveContains("Internal") != true else {
return nil
}
let matchedProfileDevice = profile.connectedDevices.first { profileDevice in
normalizedIdentifier(profileDevice.info["device_address"]) == address
}
let name = firstNonEmpty(
matchedProfileDevice?.name,
productName,
serviceClass,
localization: localization
)
let statusFlags = intProperty("BatteryStatusFlags", object: object)
let chargeState: DeviceBatteryChargeState = statusFlags == 4 ? .normal : (statusFlags ?? 0) > 0 ? .charging : .normal
let kind = inferredMagicKind(productName: productName, profileDevice: matchedProfileDevice)
return DeviceBatteryItem(
id: "ioregistry-\(address ?? name)-\(serviceClass)",
name: name,
model: productName,
kind: kind,
level: level,
chargeState: chargeState,
parentName: nil,
source: "IORegistry",
lastUpdated: referenceDate,
isConnected: true,
detail: matchedProfileDevice?.info["device_minorType"] as? String,
componentIdentity: nil
)
}
fileprivate static func componentAggregateIdentity(
groupID: String,
kind: DeviceBatteryKind
) -> DeviceBatteryComponentIdentity? {
guard kind == .airPodsPart else {
return nil
}
return DeviceBatteryComponentIdentity(groupID: groupID, role: .aggregate)
}
private static func inferredKind(
device: BluetoothProfileDevice,
field: String
) -> DeviceBatteryKind {
inferredBluetoothKind(
name: device.name,
minorType: device.info["device_minorType"] as? String,
vendorID: device.info["device_vendorID"] as? String,
field: field
)
}
static func inferredBluetoothKind(
name: String,
minorType: String?,
vendorID: String?,
field: String
) -> DeviceBatteryKind {
let name = name.lowercased()
let type = (minorType ?? "").lowercased()
let vendorID = (vendorID ?? "").lowercased()
if field == "case" || field == "left" || field == "right" {
return .airPodsPart
}
if field != "main" || name.contains("airpods") || name.contains("beats") {
if name.contains("airpods")
|| name.contains("beats")
|| type.contains("headphone")
|| type.contains("headset") {
return .airPodsPart
}
}
if vendorID == "0x004c" {
return .magicAccessory
}
return .bluetooth
}
private static func inferredMagicKind(
productName: String?,
profileDevice: BluetoothProfileDevice?
) -> DeviceBatteryKind {
let combined = [productName, profileDevice?.info["device_minorType"] as? String]
.compactMap { $0?.lowercased() }
.joined(separator: " ")
if combined.contains("mouse") || combined.contains("keyboard") || combined.contains("trackpad") {
if combined.contains("apple") || combined.contains("magic") {
return .magicAccessory
}
if profileDevice?.info["device_vendorID"] as? String == "0x004c" {
return .magicAccessory
}
return .bluetooth
}
if combined.contains("magic") {
return .magicAccessory
}
return .bluetooth
}
private static func bluetoothBatteryTargets(from profile: BluetoothProfile) -> [BluetoothBatteryTarget] {
profile.batteryDevices.map { device in
let productID = stringValue(device.info["device_productID"])
let vendorID = stringValue(device.info["device_vendorID"])
let model = productID.flatMap { HeadphoneModelCatalog.modelName(forProductID: $0) }
return BluetoothBatteryTarget(
id: normalizedIdentifier(device.info["device_address"]) ?? device.name,
name: device.name,
address: normalizedIdentifier(device.info["device_address"]),
vendorID: vendorID,
productID: productID,
model: model,
kind: inferredKind(device: device, field: "single"),
detail: device.info["device_minorType"] as? String,
isConnected: device.isConnected
)
}
}
fileprivate static func matchingBluetoothPowerLogTarget(
_ reading: DeviceBatteryBluetoothPowerLogReading,
in targets: [BluetoothBatteryTarget]
) -> BluetoothBatteryTarget? {
let candidates = targets.filter { target in
normalizedDeviceName(target.name) == normalizedDeviceName(reading.name)
&& bluetoothIdentifiersMatch(reading: reading, target: target)
}
if candidates.count == 1 {
return candidates[0]
}
return candidates.first { target in
normalizedHexIdentifier(target.vendorID) == normalizedHexIdentifier(reading.vendorID)
&& normalizedHexIdentifier(target.productID) == normalizedHexIdentifier(reading.productID)
}
}
private static func bluetoothIdentifiersMatch(
reading: DeviceBatteryBluetoothPowerLogReading,
target: BluetoothBatteryTarget
) -> Bool {
if let readingVendorID = normalizedHexIdentifier(reading.vendorID),
let targetVendorID = normalizedHexIdentifier(target.vendorID),
readingVendorID != targetVendorID {
return false
}
if let readingProductID = normalizedHexIdentifier(reading.productID),
let targetProductID = normalizedHexIdentifier(target.productID),
readingProductID != targetProductID {
return false
}
return true
}
private static func normalizedDeviceName(_ name: String) -> String {
name.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
}
private static func normalizedHexIdentifier(_ value: String?) -> String? {
let cleaned = value?
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "0x", with: "", options: .caseInsensitive)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let cleaned, !cleaned.isEmpty else {
return nil
}
return cleaned.uppercased()
}
fileprivate static func normalizedHexIdentifierForReader(_ value: String?) -> String? {
normalizedHexIdentifier(value)
}
private static func normalizedProductIdentifier(_ value: String?) -> String? {
guard let value else {
return nil
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return nil
}
let rawValue = trimmed.split(separator: " ").first.map(String.init) ?? trimmed
if rawValue.hasPrefix("0x") || rawValue.hasPrefix("0X") {
return normalizedHexIdentifier(rawValue)
}
if let decimal = Int(rawValue) {
return String(decimal, radix: 16).uppercased()
}
return normalizedHexIdentifier(rawValue)
}
static func deduplicated(_ items: [DeviceBatteryItem]) -> [DeviceBatteryItem] {
var bestByNameAndKind: [String: DeviceBatteryItem] = [:]
var orderedKeys: [String] = []
for item in DeviceBatteryItemNormalizer.removingRedundantComponentAggregates(items) {
let key = "\(item.kind)-\(item.name.lowercased())-\(item.parentName ?? "")"
if let existing = bestByNameAndKind[key] {
bestByNameAndKind[key] = preferredItem(existing, item)
} else {
bestByNameAndKind[key] = item
orderedKeys.append(key)
}
}
return orderedKeys.compactMap { bestByNameAndKind[$0] }
}
private static func preferredItem(
_ left: DeviceBatteryItem,
_ right: DeviceBatteryItem
) -> DeviceBatteryItem {
if left.source == "MobileDevice" || right.source == "MobileDevice" {
return left.source == "MobileDevice" ? left : right
}
if left.chargeState.isActiveChargingState != right.chargeState.isActiveChargingState {
return left.chargeState.isActiveChargingState ? left : right
}
let sourceRank = [
"IORegistry": 0,
"IOPowerSources": 0,
"MobileDevice": 0,
"CoreBluetooth": 1,
"AppleHeadphoneAdvertisement": 2,
"BatteryCenter": 3,
"BluetoothPowerLog": 4,
"IOBluetooth": 5,
"system_profiler": 6
]
let leftRank = sourceRank[left.source] ?? 10
let rightRank = sourceRank[right.source] ?? 10
if leftRank != rightRank {
return leftRank < rightRank ? left : right
}
return left.lastUpdated ?? .distantPast >= right.lastUpdated ?? .distantPast ? left : right
}
private static func batteryLevel(from value: Any?) -> Int? {
batteryValue(from: value)?.level
}
private static func batteryValue(from value: Any?) -> (level: Int, chargeState: DeviceBatteryChargeState)? {
if let number = value as? NSNumber {
let level = number.intValue
guard (0...100).contains(level) else {
return nil
}
return (level, .normal)
}
guard let string = value as? String else {
return nil
}
let digits = string
.replacingOccurrences(of: "%", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let level = Int(digits), (0...100).contains(level) else {
return nil
}
return (level, digits.hasPrefix("+") ? .charging : .normal)
}
private static func normalizedIdentifier(_ value: Any?) -> String? {
guard let rawValue = stringValue(value)?.trimmingCharacters(in: .whitespacesAndNewlines),
!rawValue.isEmpty
else {
return nil
}
return rawValue
.replacingOccurrences(of: "-", with: ":")
.uppercased()
}
private static func stringValue(_ value: Any?) -> String? {
if let string = value as? String {
return string
}
if let number = value as? NSNumber {
return number.stringValue
}
return nil
}
private static func firstNonEmpty(
_ values: String?...,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) -> String {
for value in values {
if let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return value
}
}
return localization.string("deviceName.bluetoothFallback", defaultValue: "蓝牙设备")
}
private static func intProperty(_ key: String, object: io_object_t) -> Int? {
guard let value = IORegistryEntryCreateCFProperty(object, key as CFString, kCFAllocatorDefault, 0) else {
return nil
}
return value.takeRetainedValue() as? Int
}
private static func stringProperty(_ key: String, object: io_object_t) -> String? {
guard let value = IORegistryEntryCreateCFProperty(object, key as CFString, kCFAllocatorDefault, 0) else {
return nil
}
return value.takeRetainedValue() as? String
}
private static func runCommand(
path: String,
arguments: [String],
timeout: TimeInterval,
outputLineFilter: ((String) -> Bool)? = nil
) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = arguments
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
let outputAccumulator = CommandOutputAccumulator(lineFilter: outputLineFilter)
outputPipe.fileHandleForReading.readabilityHandler = { handle in
outputAccumulator.append(handle.availableData)
}
errorPipe.fileHandleForReading.readabilityHandler = { handle in
_ = handle.availableData
}
do {
try process.run()
} catch {
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
outputPipe.fileHandleForReading.closeFile()
errorPipe.fileHandleForReading.closeFile()
return nil
}
let deadline = Date().addingTimeInterval(timeout)
while process.isRunning, Date() < deadline {
Thread.sleep(forTimeInterval: 0.03)
}
guard !process.isRunning else {
process.terminate()
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
outputAccumulator.append(outputPipe.fileHandleForReading.readDataToEndOfFile())
_ = errorPipe.fileHandleForReading.readDataToEndOfFile()
outputPipe.fileHandleForReading.closeFile()
errorPipe.fileHandleForReading.closeFile()
let partialOutput = outputAccumulator.output()
return outputLineFilter == nil || partialOutput.isEmpty ? nil : partialOutput
}
process.waitUntilExit()
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
outputAccumulator.append(outputPipe.fileHandleForReading.readDataToEndOfFile())
_ = errorPipe.fileHandleForReading.readDataToEndOfFile()
outputPipe.fileHandleForReading.closeFile()
errorPipe.fileHandleForReading.closeFile()
return outputAccumulator.output()
}
}
private final class CommandOutputAccumulator: @unchecked Sendable {
private let lock = NSLock()
private let lineFilter: ((String) -> Bool)?
private var bufferedOutput = ""
private var pendingLine = ""
init(lineFilter: ((String) -> Bool)?) {
self.lineFilter = lineFilter
}
func append(_ data: Data) {
guard !data.isEmpty,
let chunk = String(data: data, encoding: .utf8)
else {
return
}
lock.lock()
if let lineFilter {
appendFiltered(chunk, lineFilter: lineFilter)
} else {
bufferedOutput.append(chunk)
}
lock.unlock()
}
func output() -> String {
lock.lock()
if let lineFilter,
!pendingLine.isEmpty,
lineFilter(pendingLine) {
bufferedOutput.append(pendingLine)
bufferedOutput.append("\n")
}
pendingLine = ""
let currentOutput = bufferedOutput
lock.unlock()
return currentOutput
}
private func appendFiltered(
_ chunk: String,
lineFilter: (String) -> Bool
) {
pendingLine.append(chunk)
let lines = pendingLine.split(separator: "\n", omittingEmptySubsequences: false)
guard lines.count > 1 else {
return
}
for line in lines.dropLast() {
let text = String(line)
if lineFilter(text) {
bufferedOutput.append(text)
bufferedOutput.append("\n")
}
}
pendingLine = String(lines.last ?? "")
}
}
private struct BluetoothProfile {
let connectedDevices: [BluetoothProfileDevice]
let batteryDevices: [BluetoothProfileDevice]
}
private struct BluetoothProfileDevice {
let name: String
let info: [String: Any]
let isConnected: Bool
}
private struct DeviceBatteryBaseSample: Sendable {
let items: [DeviceBatteryItem]
let bluetoothBatteryTargets: [BluetoothBatteryTarget]
let shouldReadBatteryCenterLog: Bool
}
fileprivate struct BluetoothBatteryTarget: Sendable {
let id: String
let name: String
let address: String?
let vendorID: String?
let productID: String?
let model: String?
let kind: DeviceBatteryKind
let detail: String?
let isConnected: Bool
var componentGroupID: String {
address ?? id
}
}
struct DeviceBatteryBluetoothPowerLogReading: Equatable, Sendable {
let name: String
let vendorID: String?
let productID: String?
let deviceType: String?
let component: DeviceBatteryBluetoothPowerLogComponent?
let level: Int
let chargeState: DeviceBatteryChargeState
}
enum DeviceBatteryBluetoothPowerLogComponent: String, Sendable {
case left
case right
case chargingCase
func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
switch self {
case .left:
return localization.string("component.left", defaultValue: "左耳")
case .right:
return localization.string("component.right", defaultValue: "右耳")
case .chargingCase:
return localization.string("component.chargingCase", defaultValue: "充电盒")
}
}
var idSuffix: String {
switch self {
case .left:
return "left"
case .right:
return "right"
case .chargingCase:
return "case"
}
}
var componentRole: DeviceBatteryComponentRole {
switch self {
case .left:
return .left
case .right:
return .right
case .chargingCase:
return .chargingCase
}
}
}
enum DeviceBatteryBluetoothPowerLogParser {
static func readings(from output: String) -> [DeviceBatteryBluetoothPowerLogReading] {
var latestByIdentity: [String: DeviceBatteryBluetoothPowerLogReading] = [:]
for line in output.split(whereSeparator: \.isNewline) {
for reading in readings(fromLine: String(line)) {
latestByIdentity[identityKey(for: reading)] = reading
}
}
return latestByIdentity.keys.sorted().compactMap { latestByIdentity[$0] }
}
static func reading(from line: String) -> DeviceBatteryBluetoothPowerLogReading? {
readings(fromLine: line).first { $0.component == nil }
}
static func readings(fromLine line: String) -> [DeviceBatteryBluetoothPowerLogReading] {
guard line.contains("CBPowerSource"),
line.contains("Power source updated"),
let name = stringValue(after: "Nm '", before: "'", in: line)
else {
return []
}
let vendorID = stringValue(after: "VID ", before: " ", in: line)
let productID = stringValue(after: "PID ", before: " ", in: line)
let deviceType = stringValue(after: "AcCa ", before: ",", in: line)
var readings: [DeviceBatteryBluetoothPowerLogReading] = []
if let batteryValue = batteryPercentValue(after: "Battery ", in: line)
?? batteryPercentValue(after: "Battery M ", in: line) {
readings.append(DeviceBatteryBluetoothPowerLogReading(
name: name,
vendorID: vendorID,
productID: productID,
deviceType: deviceType,
component: nil,
level: min(max(abs(batteryValue.level), 0), 100),
chargeState: batteryValue.isCharging ? .charging : .normal
))
}
readings.append(contentsOf: componentBatteryValues(in: line).map { component, batteryValue in
DeviceBatteryBluetoothPowerLogReading(
name: name,
vendorID: vendorID,
productID: productID,
deviceType: deviceType,
component: component,
level: min(max(abs(batteryValue.level), 0), 100),
chargeState: batteryValue.isCharging ? .charging : .normal
)
})
return readings
}
private static func componentBatteryValues(
in line: String
) -> [(DeviceBatteryBluetoothPowerLogComponent, (level: Int, isCharging: Bool))] {
[
(.left, "Left "),
(.right, "Right "),
(.chargingCase, "Case ")
]
.compactMap { component, prefix in
guard let batteryValue = batteryPercentValue(after: prefix, in: line) else {
return nil
}
return (component, batteryValue)
}
}
private static func identityKey(for reading: DeviceBatteryBluetoothPowerLogReading) -> String {
[
reading.name,
reading.vendorID ?? "",
reading.productID ?? "",
reading.component?.rawValue ?? "main"
]
.joined(separator: "|")
.lowercased()
}
private static func stringValue(
after prefix: String,
before suffix: Character,
in text: String
) -> String? {
guard let startRange = text.range(of: prefix) else {
return nil
}
let start = startRange.upperBound
guard let end = text[start...].firstIndex(of: suffix) else {
return nil
}
let value = String(text[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
private static func batteryPercentValue(
after prefix: String,
in text: String
) -> (level: Int, isCharging: Bool)? {
guard let startRange = text.range(of: prefix) else {
return nil
}
let remainder = text[startRange.upperBound...]
var value = ""
for character in remainder {
if character == "+" || character == "-" || character.isNumber {
value.append(character)
continue
}
break
}
guard !value.isEmpty else {
return nil
}
guard let level = Int(value) else {
return nil
}
return (level: level, isCharging: value.hasPrefix("+"))
}
}
struct DeviceBatteryBatteryCenterLogReading: Equatable, Sendable {
let name: String?
let groupName: String?
let productID: String?
let model: String?
let category: String?
let accessoryID: String?
let transportType: String?
let level: Int
let chargeState: DeviceBatteryChargeState
let isConnected: Bool?
let isInternal: Bool?
}
enum DeviceBatteryBatteryCenterLogParser {
static func readings(from output: String) -> [DeviceBatteryBatteryCenterLogReading] {
output
.split(whereSeparator: \.isNewline)
.compactMap { reading(fromLine: String($0)) }
}
static func reading(fromLine line: String) -> DeviceBatteryBatteryCenterLogReading? {
guard line.contains("BCBatteryDevice"),
let payload = stringValue(after: "<BCBatteryDevice:", before: ">", in: line),
let level = intField("percentCharge", in: payload),
(0...100).contains(level)
else {
return nil
}
return DeviceBatteryBatteryCenterLogReading(
name: field("name", in: payload),
groupName: field("groupName", in: payload),
productID: field("productIdentifier", in: payload),
model: field("modelNumber", in: payload),
category: field("accessoryCategory", in: payload),
accessoryID: field("accessoryIdentifier", in: payload),
transportType: field("transportType", in: payload),
level: level,
chargeState: boolField("charging", in: payload) == true ? .charging : .normal,
isConnected: boolField("connected", in: payload),
isInternal: boolField("internal", in: payload)
)
}
private static func field(_ key: String, in text: String) -> String? {
for segment in text.split(separator: ";", omittingEmptySubsequences: false) {
let parts = segment.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2,
parts[0].trimmingCharacters(in: .whitespacesAndNewlines) == key
else {
continue
}
let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty, value != "(null)" else {
return nil
}
return value
}
return nil
}
private static func intField(_ key: String, in text: String) -> Int? {
field(key, in: text).flatMap(Int.init)
}
private static func boolField(_ key: String, in text: String) -> Bool? {
switch field(key, in: text)?.uppercased() {
case "YES":
return true
case "NO":
return false
default:
return nil
}
}
private static func stringValue(
after prefix: String,
before suffix: Character,
in text: String
) -> String? {
guard let startRange = text.range(of: prefix) else {
return nil
}
let start = startRange.upperBound
guard let end = text[start...].firstIndex(of: suffix) else {
return nil
}
let value = String(text[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
struct DeviceBatteryAppleHeadphoneAdvertisementReading: Equatable, Sendable {
let component: DeviceBatteryBluetoothPowerLogComponent
let level: Int
let chargeState: DeviceBatteryChargeState
}
enum DeviceBatteryAppleHeadphoneAdvertisementParser {
static func readings(from manufacturerData: Data) -> [DeviceBatteryAppleHeadphoneAdvertisementReading] {
let bytes = [UInt8](manufacturerData)
guard bytes.count >= 2, bytes[0] == 0x4C, bytes[1] == 0x00 else {
return []
}
switch bytes.count {
case 29 where bytes[2] == 0x07:
return openCaseReadings(from: bytes)
case 25 where bytes[2] == 0x12:
return closedCaseReadings(from: bytes)
default:
return []
}
}
private static func openCaseReadings(from bytes: [UInt8]) -> [DeviceBatteryAppleHeadphoneAdvertisementReading] {
let flip = (bytes[7] & 0x02) == 0
return [
reading(component: .chargingCase, rawLevel: bytes[16]),
reading(component: .left, rawLevel: bytes[flip ? 15 : 14]),
reading(component: .right, rawLevel: bytes[flip ? 14 : 15])
]
.compactMap { $0 }
}
private static func closedCaseReadings(from bytes: [UInt8]) -> [DeviceBatteryAppleHeadphoneAdvertisementReading] {
[
reading(component: .chargingCase, rawLevel: bytes[12]),
reading(component: .left, rawLevel: bytes[13]),
reading(component: .right, rawLevel: bytes[14])
]
.compactMap { $0 }
}
private static func reading(
component: DeviceBatteryBluetoothPowerLogComponent,
rawLevel: UInt8
) -> DeviceBatteryAppleHeadphoneAdvertisementReading? {
guard rawLevel != 0xFF else {
return nil
}
let isCharging = rawLevel > 100
let level = Int(rawLevel & 0x7F)
guard (0...100).contains(level) else {
return nil
}
return DeviceBatteryAppleHeadphoneAdvertisementReading(
component: component,
level: level,
chargeState: isCharging ? .charging : .normal
)
}
}
@MainActor
private final class DeviceBatteryAppleHeadphoneAdvertisementReader: NSObject, @preconcurrency CBCentralManagerDelegate {
private let targets: [BluetoothBatteryTarget]
private let targetsByName: [String: BluetoothBatteryTarget]
private let referenceDate: Date
private let localization: PluginLocalization
private var centralManager: CBCentralManager?
private var continuations: [CheckedContinuation<[DeviceBatteryItem], Never>] = []
private var readingsByTargetID: [String: [DeviceBatteryBluetoothPowerLogComponent: DeviceBatteryAppleHeadphoneAdvertisementReading]] = [:]
private var timeoutTask: Task<Void, Never>?
private var didFinish = false
static func collectBatteryDevices(
targets: [BluetoothBatteryTarget],
referenceDate: Date,
localization: PluginLocalization
) async -> [DeviceBatteryItem] {
let eligibleTargets = targets.filter { target in
target.vendorID.flatMap(DeviceBatterySampler.normalizedHexIdentifierForReader) == "004C"
&& DeviceBatteryAppleHeadphoneAdvertisementReader.supportsAdvertisementBattery(target: target)
}
guard !eligibleTargets.isEmpty else {
return []
}
let reader = DeviceBatteryAppleHeadphoneAdvertisementReader(
targets: eligibleTargets,
referenceDate: referenceDate,
localization: localization
)
return await reader.collect()
}
init(
targets: [BluetoothBatteryTarget],
referenceDate: Date,
localization: PluginLocalization
) {
self.targets = targets
self.targetsByName = Dictionary(uniqueKeysWithValues: targets.map { ($0.name.lowercased(), $0) })
self.referenceDate = referenceDate
self.localization = localization
super.init()
}
private func collect() async -> [DeviceBatteryItem] {
await withCheckedContinuation { continuation in
continuations.append(continuation)
centralManager = CBCentralManager(delegate: self, queue: .main)
timeoutTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(2500))
self?.finish()
}
}
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
guard central.state == .poweredOn else {
finish()
return
}
central.scanForPeripherals(
withServices: nil,
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
guard let name = peripheral.name?.trimmingCharacters(in: .whitespacesAndNewlines),
let target = targetsByName[name.lowercased()],
let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
else {
return
}
let readings = DeviceBatteryAppleHeadphoneAdvertisementParser.readings(from: manufacturerData)
guard !readings.isEmpty else {
return
}
var targetReadings = readingsByTargetID[target.id] ?? [:]
for reading in readings {
targetReadings[reading.component] = reading
}
readingsByTargetID[target.id] = targetReadings
let hasChargingState = targetReadings.values.contains { $0.chargeState == .charging }
if hasChargingState || readingsByTargetID.count == targets.count {
finish()
}
}
private func finish() {
guard !didFinish else {
return
}
didFinish = true
timeoutTask?.cancel()
centralManager?.stopScan()
let items = batteryItems()
let pendingContinuations = continuations
continuations.removeAll()
for continuation in pendingContinuations {
continuation.resume(returning: items)
}
}
private func batteryItems() -> [DeviceBatteryItem] {
readingsByTargetID.flatMap { targetID, readingsByComponent -> [DeviceBatteryItem] in
guard let target = targets.first(where: { $0.id == targetID }) else {
return []
}
return readingsByComponent.values.map { reading in
DeviceBatteryItem(
id: "apple-headphone-advertisement-\(target.componentGroupID)-\(reading.component.idSuffix)",
name: DeviceBatterySampler.powerLogItemNameForReader(
component: reading.component,
targetName: target.name,
localization: localization
),
model: target.model,
kind: .airPodsPart,
level: reading.level,
chargeState: reading.chargeState,
parentName: DeviceBatterySampler.powerLogParentNameForReader(
component: reading.component,
targetName: target.name,
localization: localization
),
source: "AppleHeadphoneAdvertisement",
lastUpdated: referenceDate,
isConnected: target.isConnected,
detail: target.detail,
componentIdentity: DeviceBatteryComponentIdentity(
groupID: target.componentGroupID,
role: reading.component.componentRole
)
)
}
}
}
private static func supportsAdvertisementBattery(target: BluetoothBatteryTarget) -> Bool {
let haystack = [
target.name,
target.model,
target.detail
]
.compactMap { $0?.lowercased() }
.joined(separator: " ")
return haystack.contains("airpods") || haystack.contains("beats")
}
}
@MainActor
private final class DeviceBatteryBLEBatteryReader: NSObject, @preconcurrency CBCentralManagerDelegate, @preconcurrency CBPeripheralDelegate {
private static let batteryService = CBUUID(string: "180F")
private static let deviceInformationService = CBUUID(string: "180A")
private static let batteryLevelCharacteristic = CBUUID(string: "2A19")
private static let modelNumberCharacteristic = CBUUID(string: "2A24")
private static let manufacturerNameCharacteristic = CBUUID(string: "2A29")
private let targetsByName: [String: BluetoothBatteryTarget]
private let referenceDate: Date
private let localization: PluginLocalization
private var centralManager: CBCentralManager?
private var continuations: [CheckedContinuation<[DeviceBatteryItem], Never>] = []
private var peripheralsByID: [UUID: CBPeripheral] = [:]
private var pendingPeripheralIDs: Set<UUID> = []
private var readingByID: [UUID: BluetoothBatteryReading] = [:]
private var discoveredNames: Set<String> = []
private var timeoutTask: Task<Void, Never>?
private var didFinish = false
static func collectBatteryDevices(
targets: [BluetoothBatteryTarget],
referenceDate: Date,
localization: PluginLocalization
) async -> [DeviceBatteryItem] {
let eligibleTargets = targets.filter { target in
target.isConnected && (target.kind == .bluetooth || target.kind == .magicAccessory)
}
guard !eligibleTargets.isEmpty else {
return []
}
let reader = DeviceBatteryBLEBatteryReader(
targets: eligibleTargets,
referenceDate: referenceDate,
localization: localization
)
return await reader.collect()
}
init(
targets: [BluetoothBatteryTarget],
referenceDate: Date,
localization: PluginLocalization
) {
self.targetsByName = Dictionary(
uniqueKeysWithValues: targets.map { ($0.name.lowercased(), $0) }
)
self.referenceDate = referenceDate
self.localization = localization
super.init()
}
private func collect() async -> [DeviceBatteryItem] {
await withCheckedContinuation { continuation in
continuations.append(continuation)
centralManager = CBCentralManager(delegate: self, queue: .main)
timeoutTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(3))
self?.finish()
}
}
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
guard central.state == .poweredOn else {
finish()
return
}
let connectedPeripherals = central.retrieveConnectedPeripherals(
withServices: [Self.batteryService]
)
for peripheral in connectedPeripherals {
register(peripheral, central: central)
}
central.scanForPeripherals(
withServices: [Self.batteryService],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
if connectedPeripherals.isEmpty {
timeoutTask?.cancel()
timeoutTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(2))
self?.finish()
}
}
}
func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
register(peripheral, central: central)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
discoverServices(for: peripheral)
}
func centralManager(
_ central: CBCentralManager,
didFailToConnect peripheral: CBPeripheral,
error: Error?
) {
pendingPeripheralIDs.remove(peripheral.identifier)
finishIfSettled()
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard error == nil, let services = peripheral.services else {
pendingPeripheralIDs.remove(peripheral.identifier)
finishIfSettled()
return
}
let wantedServices = services.filter { service in
service.uuid == Self.batteryService || service.uuid == Self.deviceInformationService
}
guard !wantedServices.isEmpty else {
pendingPeripheralIDs.remove(peripheral.identifier)
finishIfSettled()
return
}
for service in wantedServices {
peripheral.discoverCharacteristics(
[
Self.batteryLevelCharacteristic,
Self.modelNumberCharacteristic,
Self.manufacturerNameCharacteristic
],
for: service
)
}
}
func peripheral(
_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?
) {
guard error == nil, let characteristics = service.characteristics else {
pendingPeripheralIDs.remove(peripheral.identifier)
finishIfSettled()
return
}
var didRead = false
for characteristic in characteristics {
if characteristic.uuid == Self.batteryLevelCharacteristic
|| characteristic.uuid == Self.modelNumberCharacteristic
|| characteristic.uuid == Self.manufacturerNameCharacteristic {
didRead = true
peripheral.readValue(for: characteristic)
}
}
if !didRead, service.uuid == Self.batteryService {
pendingPeripheralIDs.remove(peripheral.identifier)
finishIfSettled()
}
}
func peripheral(
_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?
) {
defer {
if readingByID[peripheral.identifier]?.level != nil {
pendingPeripheralIDs.remove(peripheral.identifier)
finishIfSettled()
}
}
guard error == nil, let value = characteristic.value else {
return
}
var reading = readingByID[peripheral.identifier] ?? BluetoothBatteryReading()
switch characteristic.uuid {
case Self.batteryLevelCharacteristic:
guard let level = value.first, level <= 100 else {
return
}
reading.level = Int(level)
case Self.modelNumberCharacteristic:
reading.model = String(data: value, encoding: .utf8)
case Self.manufacturerNameCharacteristic:
reading.manufacturer = String(data: value, encoding: .utf8)
default:
return
}
readingByID[peripheral.identifier] = reading
}
private func register(_ peripheral: CBPeripheral, central: CBCentralManager) {
guard let name = peripheral.name?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty,
targetsByName[name.lowercased()] != nil,
!discoveredNames.contains(name.lowercased())
else {
return
}
discoveredNames.insert(name.lowercased())
peripheralsByID[peripheral.identifier] = peripheral
pendingPeripheralIDs.insert(peripheral.identifier)
peripheral.delegate = self
if peripheral.state == .connected {
discoverServices(for: peripheral)
} else {
central.connect(peripheral, options: nil)
}
}
private func discoverServices(for peripheral: CBPeripheral) {
peripheral.discoverServices([Self.batteryService, Self.deviceInformationService])
}
private func finishIfSettled() {
if !pendingPeripheralIDs.isEmpty {
return
}
finish()
}
private func finish() {
guard !didFinish else {
return
}
didFinish = true
timeoutTask?.cancel()
centralManager?.stopScan()
let items = batteryItems()
let pendingContinuations = continuations
continuations.removeAll()
for continuation in pendingContinuations {
continuation.resume(returning: items)
}
}
private func batteryItems() -> [DeviceBatteryItem] {
readingByID.compactMap { peripheralID, reading in
guard let peripheral = peripheralsByID[peripheralID],
let name = peripheral.name,
let target = targetsByName[name.lowercased()],
let level = reading.level
else {
return nil
}
return DeviceBatteryItem(
id: "corebluetooth-\(target.address ?? target.id)",
name: target.name,
model: firstNonEmpty(reading.model, target.model),
kind: target.kind,
level: level,
chargeState: .normal,
parentName: nil,
source: "CoreBluetooth",
lastUpdated: referenceDate,
isConnected: true,
detail: firstNonEmpty(reading.manufacturer, target.detail),
componentIdentity: DeviceBatterySampler.componentAggregateIdentity(
groupID: target.componentGroupID,
kind: target.kind
)
)
}
}
private func firstNonEmpty(_ values: String?...) -> String? {
for value in values {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty {
return trimmed
}
}
return nil
}
}
private struct BluetoothBatteryReading {
var level: Int?
var model: String?
var manufacturer: String?
}
private extension IOBluetoothDevice {
var pluginBatteryPercentSingle: Int? {
pluginValue(forKey: "batteryPercentSingle") as? Int
}
func pluginValue(forKey key: String) -> Any? {
guard responds(to: Selector(key)) else {
return nil
}
return value(forKey: key)
}
}
enum HeadphoneModelCatalog {
private static let modelNamesByProductID: [String: String] = [
"2002": "AirPods",
"2003": "Powerbeats3",
"2005": "BeatsX",
"2006": "Beats Solo3",
"200e": "AirPods Pro",
"200a": "AirPods Max",
"200b": "Powerbeats Pro",
"200c": "Beats Solo Pro",
"200d": "Powerbeats4",
"200f": "AirPods 2",
"2010": "Beats Flex",
"2011": "Beats Studio Buds",
"2012": "Beats Fit Pro",
"2013": "AirPods 3",
"2014": "AirPods Pro 2",
"2016": "Beats Studio Buds+",
"2017": "Beats Studio Pro",
"2019": "AirPods 4",
"201b": "AirPods 4",
"201d": "AirPods Pro 2",
"201f": "AirPods Max",
"2024": "AirPods Pro 2",
"2026": "Beats Solo Buds",
"2027": "Powerbeats Pro 2"
]
static func modelName(forProductID productID: String) -> String? {
let normalized = productID
.replacingOccurrences(of: "0x", with: "")
.lowercased()
return modelNamesByProductID[normalized]
}
}