mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 23:24:32 +00:00
1795 lines
64 KiB
Swift
1795 lines
64 KiB
Swift
//
|
||
// BluetoothAudioManager.swift
|
||
// DynamicIsland
|
||
//
|
||
// Created for Bluetooth audio device connection detection and monitoring
|
||
// Detects when audio devices connect and displays HUD with battery status
|
||
//
|
||
|
||
import Foundation
|
||
import Combine
|
||
import AppKit
|
||
import Defaults
|
||
import SwiftUI
|
||
import IOBluetooth
|
||
import IOKit
|
||
import CoreBluetooth
|
||
|
||
/// Manages detection and monitoring of Bluetooth audio device connections
|
||
class BluetoothAudioManager: ObservableObject {
|
||
static let shared = BluetoothAudioManager()
|
||
|
||
// MARK: - Published Properties
|
||
@Published var lastConnectedDevice: BluetoothAudioDevice?
|
||
@Published var connectedDevices: [BluetoothAudioDevice] = []
|
||
@Published var isBluetoothAudioConnected: Bool = false
|
||
|
||
// MARK: - Private Properties
|
||
private var observers: [NSObjectProtocol] = []
|
||
private var cancellables = Set<AnyCancellable>()
|
||
private let coordinator = DynamicIslandViewCoordinator.shared
|
||
private var pollingTimer: Timer?
|
||
private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth"
|
||
private let batteryReader = BluetoothLEBatteryReader()
|
||
private var isLiveBatteryRefreshInFlight = false
|
||
|
||
@Published private(set) var batteryStatus: [String: String] = [:]
|
||
|
||
private var batteryStatusByAddress: [String: Int] = [:]
|
||
private var batteryStatusByName: [String: Int] = [:]
|
||
private var missingBatteryLog: Set<String> = []
|
||
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
|
||
private var hudBatteryWaitTasks: [UUID: Task<Void, Never>] = [:]
|
||
private let hudBatteryWaitInterval: TimeInterval = 0.3
|
||
private let hudBatteryWaitTimeout: TimeInterval = 1.8
|
||
|
||
// MARK: - Initialization
|
||
private init() {
|
||
print("🎧 [BluetoothAudioManager] Initializing...")
|
||
setupBluetoothObservers()
|
||
checkInitialDevices()
|
||
startPollingForChanges()
|
||
}
|
||
|
||
deinit {
|
||
cleanup()
|
||
}
|
||
|
||
// MARK: - Setup Methods
|
||
|
||
/// Sets up observers for Bluetooth device connection/disconnection events
|
||
private func setupBluetoothObservers() {
|
||
print("🎧 [BluetoothAudioManager] Setting up Bluetooth observers...")
|
||
|
||
// Use DistributedNotificationCenter for IOBluetooth notifications
|
||
let dnc = DistributedNotificationCenter.default()
|
||
|
||
// Observe device connected notifications
|
||
dnc.addObserver(
|
||
self,
|
||
selector: #selector(handleDeviceConnectedNotification(_:)),
|
||
name: NSNotification.Name("IOBluetoothDeviceConnectedNotification"),
|
||
object: nil
|
||
)
|
||
|
||
// Observe device disconnected notifications
|
||
dnc.addObserver(
|
||
self,
|
||
selector: #selector(handleDeviceDisconnectedNotification(_:)),
|
||
name: NSNotification.Name("IOBluetoothDeviceDisconnectedNotification"),
|
||
object: nil
|
||
)
|
||
|
||
print("🎧 [BluetoothAudioManager] ✅ Observers registered with DistributedNotificationCenter")
|
||
}
|
||
|
||
/// Starts polling for device connection changes (fallback mechanism)
|
||
private func startPollingForChanges() {
|
||
print("🎧 [BluetoothAudioManager] Starting polling timer (3s interval)...")
|
||
|
||
pollingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
||
self?.checkForDeviceChanges()
|
||
}
|
||
}
|
||
|
||
/// Checks for device connection/disconnection changes
|
||
private func checkForDeviceChanges() {
|
||
// Check if Bluetooth is powered on
|
||
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
|
||
// Bluetooth is off - clear connected devices if any
|
||
if !connectedDevices.isEmpty {
|
||
print("🎧 [BluetoothAudioManager] âš ï¸<C3AF> Bluetooth powered off - clearing connected devices")
|
||
connectedDevices.removeAll()
|
||
isBluetoothAudioConnected = false
|
||
}
|
||
return
|
||
}
|
||
|
||
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
|
||
return
|
||
}
|
||
|
||
let currentlyConnectedAddresses = Set(
|
||
pairedDevices
|
||
.filter { $0.isConnected() && isAudioDevice($0) }
|
||
.compactMap { $0.addressString }
|
||
)
|
||
|
||
let previousAddresses = Set(connectedDevices.map { $0.address })
|
||
|
||
// Check for new connections
|
||
let newAddresses = currentlyConnectedAddresses.subtracting(previousAddresses)
|
||
if !newAddresses.isEmpty {
|
||
print("🎧 [BluetoothAudioManager] ðŸ”<C5B8> Polling detected new connection(s)")
|
||
checkForNewlyConnectedDevices()
|
||
}
|
||
|
||
// Check for disconnections
|
||
let removedAddresses = previousAddresses.subtracting(currentlyConnectedAddresses)
|
||
if !removedAddresses.isEmpty {
|
||
print("🎧 [BluetoothAudioManager] ðŸ”<C5B8> Polling detected disconnection(s)")
|
||
updateConnectedDevices()
|
||
}
|
||
}
|
||
|
||
/// Checks for already connected Bluetooth audio devices on init
|
||
private func checkInitialDevices() {
|
||
print("🎧 [BluetoothAudioManager] Checking for initially connected devices...")
|
||
|
||
// Check if Bluetooth is powered on
|
||
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
|
||
print("🎧 [BluetoothAudioManager] âš ï¸<C3AF> Bluetooth is powered off - skipping initial check")
|
||
return
|
||
}
|
||
|
||
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
|
||
print("🎧 [BluetoothAudioManager] No paired devices found")
|
||
return
|
||
}
|
||
|
||
let connectedAudioDevices = pairedDevices.filter { device in
|
||
device.isConnected() && isAudioDevice(device)
|
||
}
|
||
|
||
print("🎧 [BluetoothAudioManager] Found \(connectedAudioDevices.count) connected audio devices")
|
||
|
||
connectedDevices = connectedAudioDevices.compactMap { device in
|
||
createBluetoothAudioDevice(from: device)
|
||
}
|
||
|
||
// Update connection state
|
||
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
||
|
||
refreshBatteryLevelsForConnectedDevices()
|
||
|
||
if let lastDevice = connectedDevices.last {
|
||
lastConnectedDevice = lastDevice
|
||
print("🎧 [BluetoothAudioManager] ✅ Bluetooth audio connected: \(lastDevice.name)")
|
||
}
|
||
}
|
||
|
||
// MARK: - Device Event Handlers
|
||
|
||
/// Handles Bluetooth device connection notification from DistributedNotificationCenter
|
||
@objc private func handleDeviceConnectedNotification(_ notification: Notification) {
|
||
print("🎧 [BluetoothAudioManager] 📡 Device connection notification received")
|
||
|
||
// Re-check all devices since distributed notification doesn't contain device object
|
||
checkForNewlyConnectedDevices()
|
||
}
|
||
|
||
/// Handles Bluetooth device disconnection notification from DistributedNotificationCenter
|
||
@objc private func handleDeviceDisconnectedNotification(_ notification: Notification) {
|
||
print("🎧 [BluetoothAudioManager] 📡 Device disconnection notification received")
|
||
|
||
// Re-check all devices to update connection state
|
||
updateConnectedDevices()
|
||
}
|
||
|
||
/// Checks for newly connected devices and displays HUD for new ones
|
||
private func checkForNewlyConnectedDevices() {
|
||
// Check if Bluetooth is powered on
|
||
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
|
||
print("🎧 [BluetoothAudioManager] âš ï¸<C3AF> Bluetooth is powered off - skipping device check")
|
||
return
|
||
}
|
||
|
||
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
|
||
return
|
||
}
|
||
|
||
let currentlyConnectedDevices = pairedDevices.filter { device in
|
||
device.isConnected() && isAudioDevice(device)
|
||
}
|
||
|
||
// Find devices that are newly connected
|
||
for device in currentlyConnectedDevices {
|
||
let address = device.addressString ?? "Unknown"
|
||
|
||
// Check if this device wasn't in our list before
|
||
if !connectedDevices.contains(where: { $0.address == address }) {
|
||
print("🎧 [BluetoothAudioManager] 🎉 New audio device connected: \(device.name ?? "Unknown")")
|
||
|
||
guard let audioDevice = createBluetoothAudioDevice(from: device) else {
|
||
continue
|
||
}
|
||
|
||
// Add to connected devices
|
||
connectedDevices.append(audioDevice)
|
||
lastConnectedDevice = audioDevice
|
||
isBluetoothAudioConnected = true
|
||
|
||
refreshBatteryLevelsForConnectedDevices()
|
||
|
||
// Show HUD for new connection
|
||
if let refreshedDevice = connectedDevices.last {
|
||
showDeviceConnectedHUD(refreshedDevice)
|
||
} else {
|
||
showDeviceConnectedHUD(audioDevice)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Updates the list of connected devices (for disconnections)
|
||
private func updateConnectedDevices() {
|
||
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
|
||
return
|
||
}
|
||
|
||
let currentlyConnectedAddresses = pairedDevices
|
||
.filter { $0.isConnected() && isAudioDevice($0) }
|
||
.compactMap { $0.addressString }
|
||
|
||
// Remove disconnected devices
|
||
let removedDevices = connectedDevices.filter { device in
|
||
!currentlyConnectedAddresses.contains(device.address)
|
||
}
|
||
connectedDevices.removeAll { device in
|
||
!currentlyConnectedAddresses.contains(device.address)
|
||
}
|
||
|
||
if !removedDevices.isEmpty {
|
||
print("🎧 [BluetoothAudioManager] 👋 Audio device(s) disconnected")
|
||
removedDevices.forEach { cancelHUDBatteryWait(for: $0) }
|
||
}
|
||
|
||
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
||
|
||
refreshBatteryLevelsForConnectedDevices()
|
||
}
|
||
|
||
/// Handles Bluetooth device connection event (legacy - kept for compatibility)
|
||
private func handleDeviceConnected(_ notification: Notification) {
|
||
guard let device = notification.object as? IOBluetoothDevice else {
|
||
print("🎧 [BluetoothAudioManager] âš ï¸<C3AF> Could not extract device from notification")
|
||
return
|
||
}
|
||
|
||
// Only handle audio devices
|
||
guard isAudioDevice(device) else {
|
||
print("🎧 [BluetoothAudioManager] Device is not an audio device, ignoring")
|
||
return
|
||
}
|
||
|
||
print("🎧 [BluetoothAudioManager] 🎉 Audio device connected: \(device.name ?? "Unknown")")
|
||
|
||
guard let audioDevice = createBluetoothAudioDevice(from: device) else {
|
||
return
|
||
}
|
||
|
||
// Add to connected devices list
|
||
if !connectedDevices.contains(where: { $0.address == audioDevice.address }) {
|
||
connectedDevices.append(audioDevice)
|
||
}
|
||
|
||
// Update last connected device
|
||
lastConnectedDevice = audioDevice
|
||
isBluetoothAudioConnected = true
|
||
|
||
// Show HUD
|
||
showDeviceConnectedHUD(audioDevice)
|
||
}
|
||
|
||
/// Handles Bluetooth device disconnection event
|
||
private func handleDeviceDisconnected(_ notification: Notification) {
|
||
guard let device = notification.object as? IOBluetoothDevice else {
|
||
return
|
||
}
|
||
|
||
guard isAudioDevice(device) else {
|
||
return
|
||
}
|
||
|
||
print("🎧 [BluetoothAudioManager] 👋 Audio device disconnected: \(device.name ?? "Unknown")")
|
||
|
||
// Remove from connected devices
|
||
let address = device.addressString ?? "Unknown"
|
||
let removed = connectedDevices.filter { $0.address == address }
|
||
connectedDevices.removeAll { $0.address == address }
|
||
removed.forEach { cancelHUDBatteryWait(for: $0) }
|
||
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
||
}
|
||
|
||
// MARK: - Device Detection Helpers
|
||
|
||
/// Determines if a Bluetooth device is an audio device
|
||
private func isAudioDevice(_ device: IOBluetoothDevice) -> Bool {
|
||
// Check if device has audio service UUID
|
||
let audioServiceUUID = IOBluetoothSDPUUID(uuid16: 0x110B) // Audio Sink
|
||
let headsetServiceUUID = IOBluetoothSDPUUID(uuid16: 0x1108) // Headset
|
||
let handsfreeServiceUUID = IOBluetoothSDPUUID(uuid16: 0x111E) // Handsfree
|
||
|
||
// Check if device has any audio-related services
|
||
if device.getServiceRecord(for: audioServiceUUID) != nil {
|
||
return true
|
||
}
|
||
if device.getServiceRecord(for: headsetServiceUUID) != nil {
|
||
return true
|
||
}
|
||
if device.getServiceRecord(for: handsfreeServiceUUID) != nil {
|
||
return true
|
||
}
|
||
|
||
// Check device class (major class: Audio/Video)
|
||
let deviceClass = device.classOfDevice
|
||
let majorClass = (deviceClass >> 8) & 0x1F
|
||
let audioVideoMajorClass: UInt32 = 0x04
|
||
|
||
return majorClass == audioVideoMajorClass
|
||
}
|
||
|
||
/// Creates a BluetoothAudioDevice model from IOBluetoothDevice
|
||
private func createBluetoothAudioDevice(from device: IOBluetoothDevice) -> BluetoothAudioDevice? {
|
||
let name = device.name ?? "Bluetooth Device"
|
||
let address = device.addressString ?? "Unknown"
|
||
let batteryLevel = getBatteryLevel(from: device)
|
||
let deviceType = detectDeviceType(from: device, name: name)
|
||
|
||
return BluetoothAudioDevice(
|
||
name: name,
|
||
address: address,
|
||
batteryLevel: batteryLevel,
|
||
deviceType: deviceType
|
||
)
|
||
}
|
||
|
||
/// Extracts battery level from Bluetooth device
|
||
private func getBatteryLevel(from device: IOBluetoothDevice) -> Int? {
|
||
updateBatteryStatuses()
|
||
|
||
if let level = batteryLevelFromRegistry(forAddress: device.addressString) {
|
||
clearMissingBatteryInfo(for: device)
|
||
return level
|
||
}
|
||
|
||
if let name = device.name, let level = batteryLevelFromRegistry(forName: name) {
|
||
clearMissingBatteryInfo(for: device)
|
||
return level
|
||
}
|
||
|
||
if let level = batteryLevelFromDefaults(forAddress: device.addressString) {
|
||
clearMissingBatteryInfo(for: device)
|
||
return level
|
||
}
|
||
|
||
if let name = device.name, let level = batteryLevelFromDefaults(forName: name) {
|
||
clearMissingBatteryInfo(for: device)
|
||
return level
|
||
}
|
||
|
||
logMissingBatteryInfo(for: device)
|
||
return nil
|
||
}
|
||
|
||
/// Detects the type of audio device based on name and properties
|
||
private func detectDeviceType(from device: IOBluetoothDevice, name: String) -> BluetoothAudioDeviceType {
|
||
let lowercaseName = name.lowercased()
|
||
|
||
// Check for specific AirPods models
|
||
if lowercaseName.contains("airpods") {
|
||
if lowercaseName.contains("max") {
|
||
return .airpodsMax
|
||
} else if lowercaseName.contains("pro") {
|
||
return .airpodsPro
|
||
}
|
||
return .airpods
|
||
}
|
||
|
||
// Check for other brands
|
||
if lowercaseName.contains("beats") {
|
||
return .beats
|
||
} else if lowercaseName.contains("speaker") || lowercaseName.contains("boombox") {
|
||
return .speaker
|
||
} else if lowercaseName.contains("headphone") || lowercaseName.contains("headset") ||
|
||
lowercaseName.contains("buds") || lowercaseName.contains("earbuds") {
|
||
return .headphones
|
||
}
|
||
|
||
// Check device class for more specific detection
|
||
let deviceClass = device.classOfDevice
|
||
let minorClass = (deviceClass >> 2) & 0x3F
|
||
|
||
// Minor classes for audio devices
|
||
switch minorClass {
|
||
case 0x01: return .headphones // Wearable Headset
|
||
case 0x02: return .headphones // Hands-free
|
||
case 0x06: return .headphones // Headphones
|
||
case 0x08: return .speaker // Portable Audio
|
||
case 0x0C: return .speaker // Loudspeaker
|
||
default: return .generic
|
||
}
|
||
}
|
||
|
||
private func refreshBatteryLevelsForConnectedDevices(forceCacheRefresh: Bool = true) {
|
||
if forceCacheRefresh {
|
||
updateBatteryStatuses(force: true)
|
||
}
|
||
|
||
applyConnectedDeviceBatteryLevels()
|
||
triggerLiveBatteryRefreshIfNeeded()
|
||
}
|
||
|
||
private func applyConnectedDeviceBatteryLevels(triggerPmsetFallback: Bool = true) {
|
||
guard !connectedDevices.isEmpty else {
|
||
lastConnectedDevice = nil
|
||
return
|
||
}
|
||
|
||
var updatedDevices: [BluetoothAudioDevice] = []
|
||
for device in connectedDevices {
|
||
let refreshedLevel = bestBatteryLevel(for: device)
|
||
let updatedDevice = device.withBatteryLevel(refreshedLevel)
|
||
updatedDevices.append(updatedDevice)
|
||
|
||
if let refreshedLevel {
|
||
clearMissingBatteryInfo(forName: device.name, address: device.address)
|
||
} else {
|
||
logMissingBatteryInfo(forName: device.name, address: device.address)
|
||
}
|
||
}
|
||
|
||
connectedDevices = updatedDevices
|
||
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? {
|
||
batteryLevelFromRegistry(forAddress: device.address)
|
||
?? batteryLevelFromRegistry(forName: device.name)
|
||
?? batteryLevelFromDefaults(forAddress: device.address)
|
||
?? batteryLevelFromDefaults(forName: device.name)
|
||
?? 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 }
|
||
guard !isLiveBatteryRefreshInFlight else { return }
|
||
|
||
let lookups = coreBluetoothLookups(for: connectedDevices)
|
||
guard !lookups.isEmpty else { return }
|
||
|
||
isLiveBatteryRefreshInFlight = true
|
||
batteryReader.fetchBatteryLevels(for: lookups) { [weak self] results in
|
||
guard let self else { return }
|
||
DispatchQueue.main.async {
|
||
self.isLiveBatteryRefreshInFlight = false
|
||
self.handleLiveBatteryResults(results)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func coreBluetoothLookups(for devices: [BluetoothAudioDevice]) -> [BluetoothLEBatteryReader.Lookup] {
|
||
let snapshot = coreBluetoothCacheSnapshot()
|
||
guard snapshot.hasEntries else { return [] }
|
||
|
||
var lookups: [BluetoothLEBatteryReader.Lookup] = []
|
||
var seenUUIDs: Set<UUID> = []
|
||
|
||
for device in devices {
|
||
let normalizedAddress = normalizeBluetoothIdentifier(device.address)
|
||
let normalizedName = normalizeProductName(device.name)
|
||
guard !normalizedAddress.isEmpty || !normalizedName.isEmpty else { continue }
|
||
|
||
let uuid = snapshot.byAddress[normalizedAddress]
|
||
?? snapshot.byName[normalizedName]
|
||
|
||
guard let uuid, !seenUUIDs.contains(uuid) else { continue }
|
||
seenUUIDs.insert(uuid)
|
||
|
||
let canonicalName = snapshot.namesByUUID[uuid] ?? normalizedName
|
||
lookups.append(
|
||
.init(
|
||
uuid: uuid,
|
||
addressKey: normalizedAddress.isEmpty ? nil : normalizedAddress,
|
||
nameKey: canonicalName.isEmpty ? nil : canonicalName
|
||
)
|
||
)
|
||
}
|
||
|
||
return lookups
|
||
}
|
||
|
||
private func handleLiveBatteryResults(_ results: [BluetoothLEBatteryReader.Result]) {
|
||
guard !results.isEmpty else { return }
|
||
|
||
var didUpdate = false
|
||
|
||
for result in results {
|
||
let level = clampBatteryPercentage(result.level)
|
||
|
||
if let addressKey = result.addressKey, !addressKey.isEmpty {
|
||
let previous = batteryStatusByAddress[addressKey] ?? -1
|
||
if level > previous {
|
||
batteryStatusByAddress[addressKey] = level
|
||
batteryStatus[addressKey] = String(level)
|
||
didUpdate = true
|
||
}
|
||
}
|
||
|
||
if let nameKey = result.nameKey, !nameKey.isEmpty {
|
||
let previous = batteryStatusByName[nameKey] ?? -1
|
||
if level > previous {
|
||
batteryStatusByName[nameKey] = level
|
||
didUpdate = true
|
||
}
|
||
}
|
||
}
|
||
|
||
guard didUpdate else { return }
|
||
|
||
applyConnectedDeviceBatteryLevels()
|
||
if let level = hudBatteryLevelCandidate() {
|
||
updateActiveBluetoothHUDBattery(with: level)
|
||
}
|
||
}
|
||
|
||
private func updateActiveBluetoothHUDBattery(with level: Int?) {
|
||
guard let level else { return }
|
||
DispatchQueue.main.async {
|
||
guard self.coordinator.sneakPeek.show,
|
||
self.coordinator.sneakPeek.type == .bluetoothAudio else { return }
|
||
self.coordinator.sneakPeek.value = CGFloat(level) / 100.0
|
||
}
|
||
}
|
||
|
||
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 {
|
||
return .empty
|
||
}
|
||
|
||
var byAddress: [String: UUID] = [:]
|
||
var byName: [String: UUID] = [:]
|
||
var namesByUUID: [UUID: String] = [:]
|
||
|
||
for (uuidString, payload) in coreCache {
|
||
guard let uuid = UUID(uuidString: uuidString) else { continue }
|
||
|
||
let addressKeys = ["DeviceAddress", "Address", "BD_ADDR", "device_address"]
|
||
for key in addressKeys {
|
||
if let value = payload[key], let normalized = normalizeBluetoothIdentifier(from: value) {
|
||
byAddress[normalized] = uuid
|
||
}
|
||
}
|
||
|
||
if let serialValue = payload["SerialNumber"], let normalizedSerial = normalizeBluetoothIdentifier(from: serialValue) {
|
||
byAddress[normalizedSerial] = uuid
|
||
}
|
||
|
||
let nameKeys = ["Name", "DeviceName", "ProductName", "Product", "device_name"]
|
||
for key in nameKeys {
|
||
if let value = payload[key], let normalizedName = normalizeProductName(from: value) {
|
||
byName[normalizedName] = uuid
|
||
namesByUUID[uuid] = normalizedName
|
||
}
|
||
}
|
||
}
|
||
|
||
return CoreBluetoothCacheSnapshot(byAddress: byAddress, byName: byName, namesByUUID: namesByUUID)
|
||
}
|
||
|
||
private func normalizeBluetoothIdentifier(from value: Any) -> String? {
|
||
if let string = value as? String {
|
||
let normalized = normalizeBluetoothIdentifier(string)
|
||
return normalized.isEmpty ? nil : normalized
|
||
}
|
||
|
||
if let data = value as? Data,
|
||
let ascii = String(data: data, encoding: .utf8) {
|
||
let normalized = normalizeBluetoothIdentifier(ascii)
|
||
return normalized.isEmpty ? nil : normalized
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
private func normalizeProductName(from value: Any) -> String? {
|
||
if let string = value as? String {
|
||
let normalized = normalizeProductName(string)
|
||
return normalized.isEmpty ? nil : normalized
|
||
}
|
||
if let data = value as? Data,
|
||
let ascii = String(data: data, encoding: .utf8) {
|
||
let normalized = normalizeProductName(ascii)
|
||
return normalized.isEmpty ? nil : normalized
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private struct CoreBluetoothCacheSnapshot {
|
||
let byAddress: [String: UUID]
|
||
let byName: [String: UUID]
|
||
let namesByUUID: [UUID: String]
|
||
|
||
var hasEntries: Bool {
|
||
!byAddress.isEmpty || !byName.isEmpty
|
||
}
|
||
|
||
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] ℹï¸<C3AF> 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 }
|
||
guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
|
||
|
||
let normalizedTarget = normalizeBluetoothIdentifier(address)
|
||
var bestMatch: Int?
|
||
|
||
for (key, value) in deviceCache {
|
||
guard let payload = value as? [String: Any] else { continue }
|
||
if matchesBluetoothIdentifier(normalizedTarget, key: key, payload: payload) {
|
||
if let level = extractBatteryPercentage(from: payload) {
|
||
let clamped = clampBatteryPercentage(level)
|
||
bestMatch = max(bestMatch ?? clamped, clamped)
|
||
}
|
||
}
|
||
}
|
||
|
||
return bestMatch
|
||
}
|
||
|
||
private func batteryLevelFromDefaults(forName name: String) -> Int? {
|
||
guard !name.isEmpty else { return nil }
|
||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
|
||
guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
|
||
|
||
var bestMatch: Int?
|
||
|
||
for value in deviceCache.values {
|
||
guard let payload = value as? [String: Any] else { continue }
|
||
let candidateName = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String)
|
||
if let candidateName, candidateName.caseInsensitiveCompare(name) == .orderedSame {
|
||
if let level = extractBatteryPercentage(from: payload) {
|
||
let clamped = clampBatteryPercentage(level)
|
||
bestMatch = max(bestMatch ?? clamped, clamped)
|
||
}
|
||
}
|
||
}
|
||
|
||
return bestMatch
|
||
}
|
||
|
||
private func batteryLevelFromRegistry(forName name: String) -> Int? {
|
||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return nil }
|
||
let normalized = normalizeProductName(trimmed)
|
||
guard !normalized.isEmpty else { return nil }
|
||
if let value = batteryStatusByName[normalized] {
|
||
return clampBatteryPercentage(value)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private func updateBatteryStatuses(force: Bool = false) {
|
||
let now = Date()
|
||
if !force, let lastBatteryStatusUpdate,
|
||
now.timeIntervalSince(lastBatteryStatusUpdate) < batteryStatusUpdateInterval {
|
||
return
|
||
}
|
||
|
||
var combinedAddressPercentages: [String: Int] = [:]
|
||
var combinedNamePercentages: [String: Int] = [:]
|
||
|
||
let registry = collectRegistryBatteryLevels()
|
||
mergeBatteryLevels(into: &combinedAddressPercentages, from: registry.addresses)
|
||
mergeBatteryLevels(into: &combinedNamePercentages, from: registry.names)
|
||
|
||
let defaults = collectDefaultsBatteryLevels()
|
||
mergeBatteryLevels(into: &combinedAddressPercentages, from: defaults.addresses)
|
||
mergeBatteryLevels(into: &combinedNamePercentages, from: defaults.names)
|
||
|
||
let profiler = collectSystemProfilerBatteryLevels()
|
||
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))
|
||
}
|
||
|
||
let applyUpdates = {
|
||
self.batteryStatus = statuses
|
||
self.batteryStatusByAddress = combinedAddressPercentages
|
||
self.batteryStatusByName = combinedNamePercentages
|
||
self.lastBatteryStatusUpdate = now
|
||
}
|
||
|
||
if Thread.isMainThread {
|
||
applyUpdates()
|
||
} else {
|
||
DispatchQueue.main.sync(execute: applyUpdates)
|
||
}
|
||
}
|
||
|
||
private func mergeBatteryLevels(into target: inout [String: Int], from source: [String: Int]) {
|
||
guard !source.isEmpty else { return }
|
||
for (key, value) in source {
|
||
guard !key.isEmpty else { continue }
|
||
if let existing = target[key] {
|
||
target[key] = max(existing, value)
|
||
} else {
|
||
target[key] = value
|
||
}
|
||
}
|
||
}
|
||
|
||
private func collectRegistryBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||
var addressPercentages: [String: Int] = [:]
|
||
var namePercentages: [String: Int] = [:]
|
||
|
||
var iterator = io_iterator_t()
|
||
let matchingDict: CFDictionary = IOServiceMatching("AppleDeviceManagementHIDEventService")
|
||
|
||
let servicePort: mach_port_t
|
||
if #available(macOS 12.0, *) {
|
||
servicePort = kIOMainPortDefault
|
||
} else {
|
||
servicePort = kIOMasterPortDefault
|
||
}
|
||
|
||
let kernResult = IOServiceGetMatchingServices(servicePort, matchingDict, &iterator)
|
||
|
||
if kernResult == KERN_SUCCESS {
|
||
var entry: io_object_t = IOIteratorNext(iterator)
|
||
while entry != 0 {
|
||
if let percent = IORegistryEntryCreateCFProperty(entry, "BatteryPercent" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int {
|
||
let normalizedPercent = clampBatteryPercentage(percent)
|
||
|
||
let identifierKeys = ["DeviceAddress", "SerialNumber", "BD_ADDR"]
|
||
for key in identifierKeys {
|
||
if let identifier = stringValue(forKey: key, entry: entry) {
|
||
let normalizedIdentifier = normalizeBluetoothIdentifier(identifier)
|
||
if !normalizedIdentifier.isEmpty {
|
||
if let existing = addressPercentages[normalizedIdentifier] {
|
||
addressPercentages[normalizedIdentifier] = max(existing, normalizedPercent)
|
||
} else {
|
||
addressPercentages[normalizedIdentifier] = normalizedPercent
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let nameKeys = [
|
||
"Product",
|
||
"ProductName",
|
||
"DeviceName",
|
||
"Name",
|
||
"USB Product Name",
|
||
"Bluetooth Product Name"
|
||
]
|
||
|
||
for key in nameKeys {
|
||
if let product = stringValue(forKey: key, entry: entry) {
|
||
let normalizedName = normalizeProductName(product)
|
||
if !normalizedName.isEmpty {
|
||
if let existing = namePercentages[normalizedName] {
|
||
namePercentages[normalizedName] = max(existing, normalizedPercent)
|
||
} else {
|
||
namePercentages[normalizedName] = normalizedPercent
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
IOObjectRelease(entry)
|
||
entry = IOIteratorNext(iterator)
|
||
}
|
||
}
|
||
|
||
IOObjectRelease(iterator)
|
||
|
||
return (addressPercentages, namePercentages)
|
||
}
|
||
|
||
private func collectDefaultsBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite),
|
||
let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else {
|
||
return ([:], [:])
|
||
}
|
||
|
||
var addressPercentages: [String: Int] = [:]
|
||
var namePercentages: [String: Int] = [:]
|
||
|
||
for (key, value) in deviceCache {
|
||
guard let payload = value as? [String: Any] else { continue }
|
||
guard let level = extractBatteryPercentage(from: payload) else { continue }
|
||
let clamped = clampBatteryPercentage(level)
|
||
|
||
let normalizedKey = normalizeBluetoothIdentifier(key)
|
||
if !normalizedKey.isEmpty {
|
||
addressPercentages[normalizedKey] = max(addressPercentages[normalizedKey] ?? clamped, clamped)
|
||
}
|
||
|
||
for identifier in identifiersFromDeviceCachePayload(payload) {
|
||
addressPercentages[identifier] = max(addressPercentages[identifier] ?? clamped, clamped)
|
||
}
|
||
|
||
if let name = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String) {
|
||
let normalizedName = normalizeProductName(name)
|
||
if !normalizedName.isEmpty {
|
||
namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
|
||
}
|
||
}
|
||
}
|
||
|
||
return (addressPercentages, namePercentages)
|
||
}
|
||
|
||
private func collectSystemProfilerBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||
guard let root = systemProfilerBluetoothDictionary() else {
|
||
return ([:], [:])
|
||
}
|
||
|
||
var addressPercentages: [String: Int] = [:]
|
||
var namePercentages: [String: Int] = [:]
|
||
|
||
if let connectedList = root["device_connected"] as? [[String: [String: Any]]] {
|
||
for deviceGroup in connectedList {
|
||
for (rawName, payload) in deviceGroup {
|
||
guard let percent = extractSystemProfilerBatteryPercentage(from: payload) else { continue }
|
||
let clamped = clampBatteryPercentage(percent)
|
||
|
||
let normalizedName = normalizeProductName(rawName)
|
||
if !normalizedName.isEmpty {
|
||
namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
|
||
}
|
||
|
||
for address in profilerAddressCandidates(from: payload) {
|
||
let normalizedAddress = normalizeBluetoothIdentifier(address)
|
||
if !normalizedAddress.isEmpty {
|
||
addressPercentages[normalizedAddress] = max(addressPercentages[normalizedAddress] ?? clamped, clamped)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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<String> = []
|
||
let candidateKeys = ["DeviceAddress", "Address", "BD_ADDR", "SerialNumber"]
|
||
|
||
for key in candidateKeys {
|
||
if let value = payload[key] as? String {
|
||
let normalized = normalizeBluetoothIdentifier(value)
|
||
if !normalized.isEmpty {
|
||
identifiers.insert(normalized)
|
||
}
|
||
} else if let data = payload[key] as? Data,
|
||
let ascii = String(data: data, encoding: .utf8) {
|
||
let normalized = normalizeBluetoothIdentifier(ascii)
|
||
if !normalized.isEmpty {
|
||
identifiers.insert(normalized)
|
||
}
|
||
}
|
||
}
|
||
|
||
return Array(identifiers)
|
||
}
|
||
|
||
private func systemProfilerBluetoothDictionary() -> [String: Any]? {
|
||
let process = Process()
|
||
process.launchPath = "/usr/sbin/system_profiler"
|
||
process.arguments = ["SPBluetoothDataType", "-json"]
|
||
|
||
let outputPipe = Pipe()
|
||
process.standardOutput = outputPipe
|
||
let errorPipe = Pipe()
|
||
process.standardError = errorPipe
|
||
|
||
do {
|
||
try process.run()
|
||
} catch {
|
||
return nil
|
||
}
|
||
|
||
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||
process.waitUntilExit()
|
||
|
||
guard process.terminationStatus == 0 else {
|
||
return nil
|
||
}
|
||
guard !data.isEmpty else { return nil }
|
||
|
||
guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||
let entries = jsonObject["SPBluetoothDataType"] as? [[String: Any]],
|
||
let root = entries.first else {
|
||
return nil
|
||
}
|
||
|
||
return root
|
||
}
|
||
|
||
private func extractSystemProfilerBatteryPercentage(from payload: [String: Any]) -> Int? {
|
||
let preferredKeys = [
|
||
"device_batteryLevelCase",
|
||
"device_batteryLevelLeft",
|
||
"device_batteryLevelRight",
|
||
"device_batteryLevelMain",
|
||
"device_batteryLevel",
|
||
"device_batteryLevelCombined",
|
||
"device_batteryPercentCombined",
|
||
"Left Battery Level",
|
||
"Right Battery Level",
|
||
"Battery Level",
|
||
"BatteryPercent"
|
||
]
|
||
|
||
var values: [Int] = []
|
||
|
||
for key in preferredKeys {
|
||
if let raw = payload[key], let converted = convertToBatteryPercentage(raw) {
|
||
values.append(converted)
|
||
}
|
||
}
|
||
|
||
if values.isEmpty {
|
||
for (key, raw) in payload where key.lowercased().contains("battery") {
|
||
if let converted = convertToBatteryPercentage(raw) {
|
||
values.append(converted)
|
||
}
|
||
}
|
||
}
|
||
|
||
let validValues = values.filter { $0 >= 0 }
|
||
return validValues.max()
|
||
}
|
||
|
||
private func profilerAddressCandidates(from payload: [String: Any]) -> [String] {
|
||
var addresses: Set<String> = []
|
||
let keys = [
|
||
"device_address",
|
||
"device_mac_address",
|
||
"device_serial_num",
|
||
"device_serialNumber",
|
||
"device_serial_number"
|
||
]
|
||
|
||
for key in keys {
|
||
if let value = payload[key] as? String, !value.isEmpty {
|
||
addresses.insert(value)
|
||
} else if let data = payload[key] as? Data,
|
||
let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty {
|
||
addresses.insert(ascii)
|
||
}
|
||
}
|
||
|
||
return Array(addresses)
|
||
}
|
||
|
||
private func batteryLevelFromRegistry(forAddress address: String?) -> Int? {
|
||
guard let address, !address.isEmpty else { return nil }
|
||
let normalized = normalizeBluetoothIdentifier(address)
|
||
if let value = batteryStatusByAddress[normalized] {
|
||
return clampBatteryPercentage(value)
|
||
}
|
||
if let storedValue = batteryStatus[normalized], let value = Int(storedValue) {
|
||
return clampBatteryPercentage(value)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private func extractBatteryPercentage(from payload: [String: Any]) -> Int? {
|
||
let keys = [
|
||
"BatteryPercent",
|
||
"BatteryPercentCase",
|
||
"BatteryPercentLeft",
|
||
"BatteryPercentRight",
|
||
"BatteryPercentSingle",
|
||
"BatteryPercentCombined",
|
||
"BatteryPercentMain",
|
||
"device_batteryLevelLeft",
|
||
"device_batteryLevelRight",
|
||
"device_batteryLevelMain",
|
||
"Left Battery Level",
|
||
"Right Battery Level"
|
||
]
|
||
|
||
var values: [Int] = []
|
||
|
||
for key in keys {
|
||
guard let raw = payload[key] else { continue }
|
||
if let converted = convertToBatteryPercentage(raw) {
|
||
values.append(converted)
|
||
}
|
||
}
|
||
|
||
if values.isEmpty,
|
||
let services = payload["Services"] as? [[String: Any]] {
|
||
for service in services {
|
||
if let serviceValues = service["BatteryPercentages"] as? [String: Any] {
|
||
for value in serviceValues.values {
|
||
if let converted = convertToBatteryPercentage(value) {
|
||
values.append(converted)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return values.max()
|
||
}
|
||
|
||
private func convertToBatteryPercentage(_ value: Any) -> Int? {
|
||
if let number = value as? Int {
|
||
if number == 1 {
|
||
return 100
|
||
}
|
||
return number
|
||
}
|
||
if let number = value as? Double {
|
||
if number <= 1.0 {
|
||
return Int(number * 100)
|
||
}
|
||
return Int(number)
|
||
}
|
||
if let string = value as? String {
|
||
let trimmed = string.replacingOccurrences(of: "%", with: "")
|
||
if let doubleValue = Double(trimmed) {
|
||
if doubleValue <= 1.0 {
|
||
return Int(doubleValue * 100)
|
||
}
|
||
return Int(doubleValue)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
private func clampBatteryPercentage(_ value: Int) -> Int {
|
||
min(max(value, 0), 100)
|
||
}
|
||
|
||
private func matchesBluetoothIdentifier(_ normalizedTarget: String, key: String, payload: [String: Any]) -> Bool {
|
||
if normalizeBluetoothIdentifier(key) == normalizedTarget {
|
||
return true
|
||
}
|
||
|
||
let candidateFields: [String?] = [
|
||
payload["DeviceAddress"] as? String,
|
||
payload["Address"] as? String,
|
||
payload["BD_ADDR"] as? String,
|
||
payload["SerialNumber"] as? String
|
||
]
|
||
|
||
for field in candidateFields {
|
||
if let field, normalizeBluetoothIdentifier(field) == normalizedTarget {
|
||
return true
|
||
}
|
||
}
|
||
|
||
if let deviceAddressData = payload["DeviceAddress"] as? Data,
|
||
let ascii = String(data: deviceAddressData, encoding: .utf8),
|
||
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
|
||
return true
|
||
}
|
||
|
||
if let addressData = payload["BD_ADDR"] as? Data,
|
||
let ascii = String(data: addressData, encoding: .utf8),
|
||
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
|
||
return true
|
||
}
|
||
|
||
if let serialData = payload["SerialNumber"] as? Data,
|
||
let ascii = String(data: serialData, encoding: .utf8),
|
||
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
private func logMissingBatteryInfo(for device: IOBluetoothDevice) {
|
||
let name = device.name ?? ""
|
||
let address = device.addressString ?? ""
|
||
logMissingBatteryInfo(forName: name, address: address)
|
||
}
|
||
|
||
private func clearMissingBatteryInfo(for device: IOBluetoothDevice) {
|
||
let name = device.name ?? ""
|
||
let address = device.addressString ?? ""
|
||
clearMissingBatteryInfo(forName: name, address: address)
|
||
}
|
||
|
||
private func logMissingBatteryInfo(forName name: String, address: String) {
|
||
let key = missingBatteryKey(name: name, address: address)
|
||
guard !missingBatteryLog.contains(key) else { return }
|
||
missingBatteryLog.insert(key)
|
||
|
||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
let displayName = trimmedName.isEmpty ? "unknown device" : trimmedName
|
||
let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame
|
||
let displayAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "N/A" : trimmedAddress
|
||
print("🎧 [BluetoothAudioManager] âš ï¸<C3AF> Battery percentage unavailable for \(displayName) (\(displayAddress))")
|
||
}
|
||
|
||
private func clearMissingBatteryInfo(forName name: String, address: String) {
|
||
let key = missingBatteryKey(name: name, address: address)
|
||
missingBatteryLog.remove(key)
|
||
}
|
||
|
||
private func missingBatteryKey(name: String, address: String) -> String {
|
||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
let normalizedName = normalizeProductName(trimmedName)
|
||
|
||
let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame
|
||
let normalizedAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "" : normalizeBluetoothIdentifier(trimmedAddress)
|
||
|
||
if normalizedName.isEmpty && normalizedAddress.isEmpty {
|
||
return "unknown"
|
||
}
|
||
|
||
return normalizedName + "#" + normalizedAddress
|
||
}
|
||
|
||
private func stringValue(forKey key: String, entry: io_object_t) -> String? {
|
||
guard let unmanaged = IORegistryEntryCreateCFProperty(entry, key as CFString, kCFAllocatorDefault, 0) else {
|
||
return nil
|
||
}
|
||
|
||
let value = unmanaged.takeRetainedValue()
|
||
|
||
if let string = value as? String, !string.isEmpty {
|
||
return string
|
||
}
|
||
|
||
if let data = value as? Data, let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty {
|
||
return ascii
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
private func cancelHUDBatteryWait(for device: BluetoothAudioDevice) {
|
||
let cancelBlock = { [weak self] in
|
||
guard let self else { return }
|
||
self.hudBatteryWaitTasks[device.id]?.cancel()
|
||
self.hudBatteryWaitTasks.removeValue(forKey: device.id)
|
||
}
|
||
|
||
if Thread.isMainThread {
|
||
cancelBlock()
|
||
} else {
|
||
DispatchQueue.main.async(execute: cancelBlock)
|
||
}
|
||
}
|
||
|
||
private func normalizeBluetoothIdentifier(_ value: String) -> String {
|
||
return value
|
||
.lowercased()
|
||
.replacingOccurrences(of: ":", with: "")
|
||
.replacingOccurrences(of: "-", with: "")
|
||
.replacingOccurrences(of: " ", with: "")
|
||
}
|
||
|
||
private func normalizeProductName(_ name: String) -> String {
|
||
let components = name
|
||
.lowercased()
|
||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||
.filter { !$0.isEmpty }
|
||
|
||
return components.joined()
|
||
}
|
||
|
||
// MARK: - HUD Display
|
||
|
||
/// Shows HUD notification for newly connected audio device
|
||
private func showDeviceConnectedHUD(_ device: BluetoothAudioDevice) {
|
||
guard Defaults[.showBluetoothDeviceConnections] else { return }
|
||
|
||
cancelHUDBatteryWait(for: device)
|
||
|
||
if let battery = bestBatteryLevel(for: device) {
|
||
presentDeviceConnectedHUD(device: device, batteryLevel: battery)
|
||
return
|
||
}
|
||
|
||
requestPmsetFallback(reason: "hud missing battery")
|
||
|
||
let task = Task.detached(priority: .utility) { [weak self] in
|
||
guard let self else { return }
|
||
let deadline = Date().addingTimeInterval(self.hudBatteryWaitTimeout)
|
||
while Date() < deadline {
|
||
try? await Task.sleep(nanoseconds: UInt64(self.hudBatteryWaitInterval * 1_000_000_000))
|
||
guard !Task.isCancelled else { return }
|
||
|
||
let batteryInfo = await MainActor.run { () -> (BluetoothAudioDevice, Int)? in
|
||
guard let refreshedDevice = self.connectedDevices.first(where: { $0.id == device.id }),
|
||
let battery = self.bestBatteryLevel(for: refreshedDevice) else {
|
||
return nil
|
||
}
|
||
return (refreshedDevice, battery)
|
||
}
|
||
|
||
if let (refreshedDevice, battery) = batteryInfo {
|
||
await MainActor.run {
|
||
self.presentDeviceConnectedHUD(device: refreshedDevice, batteryLevel: battery)
|
||
}
|
||
self.cancelHUDBatteryWait(for: device)
|
||
return
|
||
}
|
||
}
|
||
|
||
guard !Task.isCancelled else { return }
|
||
await MainActor.run {
|
||
self.presentDeviceConnectedHUD(device: device, batteryLevel: nil)
|
||
}
|
||
self.cancelHUDBatteryWait(for: device)
|
||
}
|
||
|
||
hudBatteryWaitTasks[device.id] = task
|
||
}
|
||
|
||
private func presentDeviceConnectedHUD(device: BluetoothAudioDevice, batteryLevel: Int?) {
|
||
guard Defaults[.showBluetoothDeviceConnections] else { return }
|
||
|
||
print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD")
|
||
|
||
let batteryValue: CGFloat = if let batteryLevel {
|
||
CGFloat(clampBatteryPercentage(batteryLevel)) / 100.0
|
||
} else {
|
||
0.0
|
||
}
|
||
|
||
HUDSuppressionCoordinator.shared.suppressVolumeHUD(for: 1.5)
|
||
|
||
coordinator.toggleSneakPeek(
|
||
status: true,
|
||
type: .bluetoothAudio,
|
||
duration: 2.5,
|
||
value: batteryValue,
|
||
icon: device.deviceType.sfSymbol
|
||
)
|
||
}
|
||
|
||
// MARK: - Cleanup
|
||
|
||
private func cleanup() {
|
||
print("🎧 [BluetoothAudioManager] Cleaning up observers...")
|
||
|
||
pollingTimer?.invalidate()
|
||
pollingTimer = nil
|
||
|
||
let dnc = DistributedNotificationCenter.default()
|
||
dnc.removeObserver(self)
|
||
observers.removeAll()
|
||
cancellables.removeAll()
|
||
hudBatteryWaitTasks.values.forEach { $0.cancel() }
|
||
hudBatteryWaitTasks.removeAll()
|
||
}
|
||
|
||
@MainActor
|
||
func refreshConnectedDeviceBatteries() {
|
||
refreshBatteryLevelsForConnectedDevices()
|
||
}
|
||
}
|
||
|
||
// MARK: - CoreBluetooth Battery Reader
|
||
|
||
private final class BluetoothLEBatteryReader: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
|
||
struct Lookup {
|
||
let uuid: UUID
|
||
let addressKey: String?
|
||
let nameKey: String?
|
||
}
|
||
|
||
struct Result {
|
||
let uuid: UUID
|
||
let level: Int
|
||
let addressKey: String?
|
||
let nameKey: String?
|
||
}
|
||
|
||
private enum State {
|
||
case idle
|
||
case requesting
|
||
}
|
||
|
||
private static let batteryServiceUUID = CBUUID(string: "180F")
|
||
private static let batteryCharacteristicUUID = CBUUID(string: "2A19")
|
||
|
||
private let timeoutInterval: TimeInterval = 6.0
|
||
|
||
private var central: CBCentralManager!
|
||
private var state: State = .idle
|
||
private var pendingLookups: [Lookup] = []
|
||
private var lookupByUUID: [UUID: Lookup] = [:]
|
||
private var completion: (([Result]) -> Void)?
|
||
private var pendingPeripherals: [UUID: CBPeripheral] = [:]
|
||
private var results: [UUID: Result] = [:]
|
||
private var missingUUIDs: Set<UUID> = []
|
||
private var timeoutWorkItem: DispatchWorkItem?
|
||
|
||
override init() {
|
||
super.init()
|
||
central = CBCentralManager(delegate: self, queue: nil)
|
||
}
|
||
|
||
func fetchBatteryLevels(for lookups: [Lookup], completion: @escaping ([Result]) -> Void) {
|
||
guard !lookups.isEmpty else {
|
||
completion([])
|
||
return
|
||
}
|
||
|
||
guard state == .idle else {
|
||
completion([])
|
||
return
|
||
}
|
||
|
||
state = .requesting
|
||
pendingLookups = lookups
|
||
lookupByUUID = Dictionary(uniqueKeysWithValues: lookups.map { ($0.uuid, $0) })
|
||
self.completion = completion
|
||
results.removeAll()
|
||
pendingPeripherals.removeAll()
|
||
missingUUIDs = Set(lookups.map { $0.uuid })
|
||
|
||
switch central.state {
|
||
case .poweredOn:
|
||
startRequest()
|
||
case .unauthorized, .unsupported, .poweredOff:
|
||
complete(with: [])
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
// MARK: - CBCentralManagerDelegate
|
||
|
||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||
guard state == .requesting else { return }
|
||
|
||
switch central.state {
|
||
case .poweredOn:
|
||
startRequest()
|
||
case .unauthorized, .unsupported, .poweredOff:
|
||
complete(with: [])
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||
peripheral.discoverServices([Self.batteryServiceUUID])
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||
markPeripheralFinished(peripheral.identifier)
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||
guard state == .requesting else { return }
|
||
markPeripheralFinished(peripheral.identifier)
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||
guard state == .requesting else { return }
|
||
guard missingUUIDs.contains(peripheral.identifier) else { return }
|
||
|
||
missingUUIDs.remove(peripheral.identifier)
|
||
configurePeripheral(peripheral)
|
||
}
|
||
|
||
// MARK: - CBPeripheralDelegate
|
||
|
||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||
guard state == .requesting else { return }
|
||
|
||
if let error {
|
||
print("🎧 [BluetoothLEBatteryReader] Service discovery failed: \(error.localizedDescription)")
|
||
markPeripheralFinished(peripheral.identifier)
|
||
return
|
||
}
|
||
|
||
guard let service = peripheral.services?.first(where: { $0.uuid == Self.batteryServiceUUID }) else {
|
||
markPeripheralFinished(peripheral.identifier)
|
||
return
|
||
}
|
||
|
||
peripheral.discoverCharacteristics([Self.batteryCharacteristicUUID], for: service)
|
||
}
|
||
|
||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||
guard state == .requesting else { return }
|
||
|
||
if let error {
|
||
print("🎧 [BluetoothLEBatteryReader] Characteristic discovery failed: \(error.localizedDescription)")
|
||
markPeripheralFinished(peripheral.identifier)
|
||
return
|
||
}
|
||
|
||
guard let characteristic = service.characteristics?.first(where: { $0.uuid == Self.batteryCharacteristicUUID }) else {
|
||
markPeripheralFinished(peripheral.identifier)
|
||
return
|
||
}
|
||
|
||
peripheral.readValue(for: characteristic)
|
||
}
|
||
|
||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||
guard state == .requesting else { return }
|
||
|
||
defer { markPeripheralFinished(peripheral.identifier) }
|
||
|
||
if let error {
|
||
print("🎧 [BluetoothLEBatteryReader] Battery read failed: \(error.localizedDescription)")
|
||
return
|
||
}
|
||
|
||
guard let data = characteristic.value, let byte = data.first, let lookup = lookupByUUID[peripheral.identifier] else {
|
||
return
|
||
}
|
||
|
||
let level = Int(byte)
|
||
results[peripheral.identifier] = Result(
|
||
uuid: peripheral.identifier,
|
||
level: level,
|
||
addressKey: lookup.addressKey,
|
||
nameKey: lookup.nameKey
|
||
)
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
private func startRequest() {
|
||
central.stopScan()
|
||
|
||
let identifiers = Array(missingUUIDs)
|
||
if !identifiers.isEmpty {
|
||
let peripherals = central.retrievePeripherals(withIdentifiers: identifiers)
|
||
for peripheral in peripherals {
|
||
missingUUIDs.remove(peripheral.identifier)
|
||
configurePeripheral(peripheral)
|
||
}
|
||
}
|
||
|
||
let connectedPeripherals = central.retrieveConnectedPeripherals(withServices: [Self.batteryServiceUUID])
|
||
for peripheral in connectedPeripherals where missingUUIDs.contains(peripheral.identifier) {
|
||
missingUUIDs.remove(peripheral.identifier)
|
||
configurePeripheral(peripheral)
|
||
}
|
||
|
||
if !missingUUIDs.isEmpty {
|
||
central.scanForPeripherals(withServices: [Self.batteryServiceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
||
}
|
||
|
||
if pendingPeripherals.isEmpty && missingUUIDs.isEmpty {
|
||
complete(with: Array(results.values))
|
||
return
|
||
}
|
||
|
||
scheduleTimeout()
|
||
}
|
||
|
||
private func configurePeripheral(_ peripheral: CBPeripheral) {
|
||
pendingPeripherals[peripheral.identifier] = peripheral
|
||
peripheral.delegate = self
|
||
|
||
switch peripheral.state {
|
||
case .connected:
|
||
peripheral.discoverServices([Self.batteryServiceUUID])
|
||
default:
|
||
central.connect(peripheral, options: nil)
|
||
}
|
||
}
|
||
|
||
private func markPeripheralFinished(_ identifier: UUID) {
|
||
pendingPeripherals.removeValue(forKey: identifier)
|
||
missingUUIDs.remove(identifier)
|
||
|
||
if missingUUIDs.isEmpty {
|
||
central.stopScan()
|
||
}
|
||
|
||
if pendingPeripherals.isEmpty && missingUUIDs.isEmpty {
|
||
complete(with: Array(results.values))
|
||
}
|
||
}
|
||
|
||
private func scheduleTimeout() {
|
||
cancelTimeout()
|
||
let workItem = DispatchWorkItem { [weak self] in
|
||
guard let self else { return }
|
||
self.complete(with: Array(self.results.values))
|
||
}
|
||
timeoutWorkItem = workItem
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutInterval, execute: workItem)
|
||
}
|
||
|
||
private func cancelTimeout() {
|
||
timeoutWorkItem?.cancel()
|
||
timeoutWorkItem = nil
|
||
}
|
||
|
||
private func complete(with results: [Result]) {
|
||
guard state == .requesting else { return }
|
||
cancelTimeout()
|
||
central.stopScan()
|
||
state = .idle
|
||
|
||
pendingPeripherals.removeAll()
|
||
missingUUIDs.removeAll()
|
||
pendingLookups.removeAll()
|
||
lookupByUUID.removeAll()
|
||
|
||
let completion = self.completion
|
||
self.completion = nil
|
||
self.results.removeAll()
|
||
|
||
completion?(results)
|
||
}
|
||
}
|
||
|
||
// MARK: - Models
|
||
|
||
struct BluetoothAudioDevice: Identifiable {
|
||
let id: UUID
|
||
let name: String
|
||
let address: String
|
||
let batteryLevel: Int? // 0-100, nil if not available
|
||
let deviceType: BluetoothAudioDeviceType
|
||
|
||
init(
|
||
id: UUID = UUID(),
|
||
name: String,
|
||
address: String,
|
||
batteryLevel: Int?,
|
||
deviceType: BluetoothAudioDeviceType
|
||
) {
|
||
self.id = id
|
||
self.name = name
|
||
self.address = address
|
||
self.batteryLevel = batteryLevel
|
||
self.deviceType = deviceType
|
||
}
|
||
}
|
||
|
||
extension BluetoothAudioDevice {
|
||
func withBatteryLevel(_ batteryLevel: Int?) -> BluetoothAudioDevice {
|
||
BluetoothAudioDevice(
|
||
id: id,
|
||
name: name,
|
||
address: address,
|
||
batteryLevel: batteryLevel,
|
||
deviceType: deviceType
|
||
)
|
||
}
|
||
}
|
||
|
||
enum BluetoothAudioDeviceType {
|
||
case airpods
|
||
case airpodsPro
|
||
case airpodsMax
|
||
case beats
|
||
case headphones
|
||
case speaker
|
||
case generic
|
||
|
||
var sfSymbol: String {
|
||
switch self {
|
||
case .airpods:
|
||
return "airpods"
|
||
case .airpodsPro:
|
||
return "airpodspro"
|
||
case .airpodsMax:
|
||
return "airpodsmax"
|
||
case .beats:
|
||
return "beats.headphones"
|
||
case .headphones:
|
||
return "headphones"
|
||
case .speaker:
|
||
return "hifispeaker.fill"
|
||
case .generic:
|
||
return "bluetooth.circle.fill"
|
||
}
|
||
}
|
||
|
||
var displayName: String {
|
||
switch self {
|
||
case .airpods: return "AirPods"
|
||
case .airpodsPro: return "AirPods Pro"
|
||
case .airpodsMax: return "AirPods Max"
|
||
case .beats: return "Beats"
|
||
case .headphones: return "Headphones"
|
||
case .speaker: return "Speaker"
|
||
case .generic: return "Bluetooth Device"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Notification Name Constants
|
||
|
||
private let IOBluetoothDeviceConnectionNotification = "IOBluetoothDeviceConnectionNotification"
|
||
private let IOBluetoothDeviceDisconnectionNotification = "IOBluetoothDeviceDisconnectionNotification"
|