Atoll/DynamicIsland/managers/BluetoothAudioManager.swift
2025-11-26 13:12:32 +05:30

1729 lines
61 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
// 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] ⚠️ 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] 🔍 Polling detected new connection(s)")
checkForNewlyConnectedDevices()
}
// Check for disconnections
let removedAddresses = previousAddresses.subtracting(currentlyConnectedAddresses)
if !removedAddresses.isEmpty {
print("🎧 [BluetoothAudioManager] 🔍 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] ⚠️ 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] ⚠️ 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 previousCount = connectedDevices.count
connectedDevices.removeAll { device in
!currentlyConnectedAddresses.contains(device.address)
}
if connectedDevices.count < previousCount {
print("🎧 [BluetoothAudioManager] 👋 Audio device(s) disconnected")
}
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] ⚠️ 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"
connectedDevices.removeAll { $0.address == address }
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] ℹ️ 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] ⚠️ 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 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 }
print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD")
// Convert battery percentage to 0.0-1.0 value
let reportedBattery = bestBatteryLevel(for: device)
let batteryValue: CGFloat = if let battery = reportedBattery {
CGFloat(clampBatteryPercentage(battery)) / 100.0
} else {
0.0 // 0 means battery info not available
}
HUDSuppressionCoordinator.shared.suppressVolumeHUD(for: 1.5)
// Show HUD via coordinator
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()
}
@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"