2536 lines
86 KiB
Swift
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]
|
|
}
|
|
}
|