diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index ba03a9d..ae2fa61 100644 --- a/DynamicIsland/managers/BluetoothAudioManager.swift +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -40,6 +40,10 @@ class BluetoothAudioManager: ObservableObject { private var missingBatteryLog: Set = [] private var lastBatteryStatusUpdate: Date? private let batteryStatusUpdateInterval: TimeInterval = 20 + private let pmsetFetchQueue = DispatchQueue(label: "com.dynamicisland.bluetooth.pmset", qos: .utility) + private var isPmsetRefreshInFlight = false + private var lastPmsetRefreshDate: Date? + private let pmsetRefreshCooldown: TimeInterval = 5 // MARK: - Initialization private init() { @@ -423,7 +427,7 @@ class BluetoothAudioManager: ObservableObject { triggerLiveBatteryRefreshIfNeeded() } - private func applyConnectedDeviceBatteryLevels() { + private func applyConnectedDeviceBatteryLevels(triggerPmsetFallback: Bool = true) { guard !connectedDevices.isEmpty else { lastConnectedDevice = nil return @@ -446,6 +450,11 @@ class BluetoothAudioManager: ObservableObject { if let last = updatedDevices.last { lastConnectedDevice = last } + + if triggerPmsetFallback, + updatedDevices.contains(where: { $0.batteryLevel == nil }) { + requestPmsetFallback(reason: "missing battery after refresh") + } } private func bestBatteryLevel(for device: BluetoothAudioDevice) -> Int? { @@ -456,6 +465,44 @@ class BluetoothAudioManager: ObservableObject { ?? device.batteryLevel } + private func requestPmsetFallback(reason: String) { + guard connectedDevices.contains(where: { $0.batteryLevel == nil }) else { return } + guard !isPmsetRefreshInFlight else { return } + + let now = Date() + if let lastPmsetRefreshDate, + now.timeIntervalSince(lastPmsetRefreshDate) < pmsetRefreshCooldown { + return + } + + isPmsetRefreshInFlight = true + print("🎧 [BluetoothAudioManager] 🔄 Triggering pmset fallback (\(reason))") + pmsetFetchQueue.async { [weak self] in + guard let self else { return } + let entries = self.collectPmsetAccessoryBatteryEntries() + DispatchQueue.main.async { + self.handlePmsetFallbackResults(entries) + } + } + } + + private func handlePmsetFallbackResults(_ entries: [PmsetAccessoryBatteryEntry]) { + isPmsetRefreshInFlight = false + lastPmsetRefreshDate = Date() + guard !entries.isEmpty else { return } + + var updatedNames = batteryStatusByName + let newlyFilled = mergePmsetEntries(entries, into: &updatedNames, logNewEntries: true) + guard !newlyFilled.isEmpty else { return } + + batteryStatusByName = updatedNames + applyConnectedDeviceBatteryLevels(triggerPmsetFallback: false) + + if let level = hudBatteryLevelCandidate() { + updateActiveBluetoothHUDBattery(with: level) + } + } + private func triggerLiveBatteryRefreshIfNeeded() { guard !connectedDevices.isEmpty else { return } guard connectedDevices.contains(where: { $0.batteryLevel == nil }) else { return } @@ -534,10 +581,8 @@ class BluetoothAudioManager: ObservableObject { guard didUpdate else { return } applyConnectedDeviceBatteryLevels() - if let lastLevel = lastConnectedDevice?.batteryLevel { - updateActiveBluetoothHUDBattery(with: lastLevel) - } else if let fallbackLevel = connectedDevices.last(where: { $0.batteryLevel != nil })?.batteryLevel { - updateActiveBluetoothHUDBattery(with: fallbackLevel) + if let level = hudBatteryLevelCandidate() { + updateActiveBluetoothHUDBattery(with: level) } } @@ -550,6 +595,11 @@ class BluetoothAudioManager: ObservableObject { } } + private func hudBatteryLevelCandidate() -> Int? { + lastConnectedDevice?.batteryLevel + ?? connectedDevices.last(where: { $0.batteryLevel != nil })?.batteryLevel + } + private func coreBluetoothCacheSnapshot() -> CoreBluetoothCacheSnapshot { guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite), let coreCache = preferences.object(forKey: "CoreBluetoothCache") as? [String: [String: Any]] else { @@ -626,6 +676,46 @@ class BluetoothAudioManager: ObservableObject { static let empty = CoreBluetoothCacheSnapshot(byAddress: [:], byName: [:], namesByUUID: [:]) } + private struct PmsetAccessoryBatteryEntry { + let displayName: String + let normalizedName: String + let level: Int + } + + @discardableResult + private func mergePmsetEntries( + _ entries: [PmsetAccessoryBatteryEntry], + into names: inout [String: Int], + logNewEntries: Bool + ) -> [PmsetAccessoryBatteryEntry] { + guard !entries.isEmpty else { return [] } + + var newlyFilled: [PmsetAccessoryBatteryEntry] = [] + + for entry in entries { + let clamped = clampBatteryPercentage(entry.level) + let previous = names[entry.normalizedName] + + if previous == nil { + newlyFilled.append(entry) + names[entry.normalizedName] = clamped + continue + } + + if let previous, clamped > previous { + names[entry.normalizedName] = clamped + } + } + + if logNewEntries { + for entry in newlyFilled { + print("🎧 [BluetoothAudioManager] â„šī¸ pmset reported \(entry.level)% for \(entry.displayName)") + } + } + + return newlyFilled + } + private func batteryLevelFromDefaults(forAddress address: String?) -> Int? { guard let address, !address.isEmpty else { return nil } guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil } @@ -701,6 +791,9 @@ class BluetoothAudioManager: ObservableObject { mergeBatteryLevels(into: &combinedAddressPercentages, from: profiler.addresses) mergeBatteryLevels(into: &combinedNamePercentages, from: profiler.names) + let pmsetEntries = collectPmsetAccessoryBatteryEntries() + mergePmsetEntries(pmsetEntries, into: &combinedNamePercentages, logNewEntries: true) + var statuses: [String: String] = [:] for (key, value) in combinedAddressPercentages { statuses[key] = String(clampBatteryPercentage(value)) @@ -867,6 +960,71 @@ class BluetoothAudioManager: ObservableObject { return (addressPercentages, namePercentages) } + private func collectPmsetAccessoryBatteryEntries() -> [PmsetAccessoryBatteryEntry] { + let process = Process() + process.launchPath = "/usr/bin/pmset" + process.arguments = ["-g", "accps"] + + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = Pipe() + + do { + try process.run() + } catch { + return [] + } + + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + return [] + } + + guard !data.isEmpty, let output = String(data: data, encoding: .utf8) else { + return [] + } + + guard let regex = try? NSRegularExpression( + pattern: #"^\s*-\s*(.+?)\s*(?:\(.+?\))?\s+(\d+)\s*%"#, + options: [.anchorsMatchLines] + ) else { + return [] + } + + var entries: [PmsetAccessoryBatteryEntry] = [] + let nsOutput = output as NSString + let range = NSRange(location: 0, length: nsOutput.length) + + regex.enumerateMatches(in: output, options: [], range: range) { match, _, _ in + guard let match, match.numberOfRanges >= 3 else { return } + + let rawName = nsOutput + .substring(with: match.range(at: 1)) + .trimmingCharacters(in: .whitespacesAndNewlines) + let percentString = nsOutput.substring(with: match.range(at: 2)) + + guard !rawName.isEmpty, let level = Int(percentString) else { return } + + let normalizedName = normalizeProductName(rawName) + guard !normalizedName.isEmpty else { return } + if normalizedName.hasPrefix("internalbattery") { + return + } + + entries.append( + PmsetAccessoryBatteryEntry( + displayName: rawName, + normalizedName: normalizedName, + level: level + ) + ) + } + + return entries + } + private func identifiersFromDeviceCachePayload(_ payload: [String: Any]) -> [String] { var identifiers: Set = [] let candidateKeys = ["DeviceAddress", "Address", "BD_ADDR", "SerialNumber"]