/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
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()
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 = []
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] = [:]
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] â ī¸ 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 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] â ī¸ 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 = []
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 = []
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 = []
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 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)
Task { @MainActor in
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()
}
@MainActor
func activeDeviceIconSymbol() -> String? {
if let prioritizedDevice = connectedDevices.last ?? lastConnectedDevice {
return prioritizedDevice.deviceType.sfSymbol
}
return nil
}
}
// 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 = []
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"