import Foundation import IOBluetooth import IOKit import CoreAudio import AudioToolbox public final class BluetoothTracker: ObservableObject { public static let shared = BluetoothTracker() @Published public var activeHeadphone: BluetoothDeviceModel? @Published public var devices: [BluetoothDeviceModel] = [] private var timer: Timer? private var lastKnownBattery: [String: Int] = [:] private typealias IntBatteryGetter = @convention(c) (AnyObject, Selector) -> Int private typealias BoolGetter = @convention(c) (AnyObject, Selector) -> Bool private init() { setupBluetoothListeners() startTracking() } deinit { stopTracking() } public func startTracking() { stopTracking() updateDeviceList() timer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in self?.updateDeviceList() } } public func stopTracking() { timer?.invalidate() timer = nil } public func refreshNow() { updateDeviceList() } private func setupBluetoothListeners() { NotificationCenter.default.addObserver( self, selector: #selector(handleBluetoothChange), name: NSNotification.Name("IOBluetoothDeviceWasConnectedNotification"), object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(handleBluetoothChange), name: NSNotification.Name("IOBluetoothDeviceWasDisconnectedNotification"), object: nil ) } @objc private func handleBluetoothChange() { DispatchQueue.main.async { [weak self] in self?.updateDeviceList() } // Retries during the first 3 seconds to catch the telemetry packet as soon as handshake finishes for delay in [0.4, 0.8, 1.2, 1.8, 2.5] { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in self?.updateDeviceList() } } } public func updateDeviceList() { var updatedList: [BluetoothDeviceModel] = [] let activeAudioName = getActiveCoreAudioDeviceName() // 1. Scan IOBluetooth paired devices if let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] { for device in pairedDevices { let name = device.nameOrAddress ?? "Unknown Device" let address = device.addressString ?? UUID().uuidString let isAudioOutput = activeAudioName != nil && (name.caseInsensitiveCompare(activeAudioName!) == .orderedSame || activeAudioName!.localizedCaseInsensitiveContains(name) || name.localizedCaseInsensitiveContains(activeAudioName!)) let isConnected = device.isConnected() || isAudioOutput let majorClass = UInt32(device.deviceClassMajor) let minorClass = UInt32(device.deviceClassMinor) let isAudio = (majorClass == 4) || isLikelyHeadphones(name: name, minorClass: minorClass) || isAudioOutput var isMulti = false let selMulti = Selector(("isMultiBatteryDevice")) if device.responds(to: selMulti) { let imp = device.method(for: selMulti) let fn = unsafeBitCast(imp, to: BoolGetter.self) isMulti = fn(device, selMulti) } var model = BluetoothDeviceModel( name: name, address: address, isConnected: isConnected, isAudioDevice: isAudio, isMultiBattery: isMulti, majorClass: majorClass, minorClass: minorClass, lastUpdated: Date() ) if isConnected { extractBatteryLevels(from: device, into: &model, isMulti: isMulti) } updatedList.append(model) } } // 2. IOKit Registry enrich enrichFromIORegistry(devices: &updatedList) // 3. Sort updatedList.sort { d1, d2 in if d1.isConnected != d2.isConnected { return d1.isConnected && !d2.isConnected } if d1.isAudioDevice != d2.isAudioDevice { return d1.isAudioDevice && !d2.isAudioDevice } return d1.name.localizedCaseInsensitiveCompare(d2.name) == .orderedAscending } let foundActive = updatedList.first(where: { $0.isConnected && $0.isAudioDevice }) ?? updatedList.first(where: { $0.isConnected }) let applyUpdate = { [weak self] in guard let self = self else { return } self.objectWillChange.send() self.devices = updatedList self.activeHeadphone = foundActive } if Thread.isMainThread { applyUpdate() } else { DispatchQueue.main.async(execute: applyUpdate) } } private func getActiveCoreAudioDeviceName() -> String? { var defaultOutputDeviceID = AudioDeviceID(0) var propertySize = UInt32(MemoryLayout.size) var propertyAddress = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) let status = AudioObjectGetPropertyData( AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, &propertySize, &defaultOutputDeviceID ) guard status == noErr else { return nil } var nameSize = UInt32(MemoryLayout.size) var nameAddress = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyDeviceNameCFString, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) var deviceName: CFString = "" as CFString let nameStatus = withUnsafeMutablePointer(to: &deviceName) { ptr in AudioObjectGetPropertyData( defaultOutputDeviceID, &nameAddress, 0, nil, &nameSize, ptr ) } if nameStatus == noErr { return deviceName as String } return nil } private func isLikelyHeadphones(name: String, minorClass: UInt32) -> Bool { let lower = name.lowercased() let keywords = ["buds", "pods", "freebuds", "airpods", "headphone", "headset", "earphone", "earbuds", "wh-", "wf-", "qc", "bose", "sony", "jbl", "beats", "marshall", "sennheiser", "soundcore", "pixel buds", "galaxy buds"] for kw in keywords { if lower.contains(kw) { return true } } if minorClass >= 1 && minorClass <= 6 { return true } return false } private func extractBatteryLevels(from device: IOBluetoothDevice, into model: inout BluetoothDeviceModel, isMulti: Bool) { func getBatteryValue(selectorName: String) -> Int? { let sel = Selector((selectorName)) guard device.responds(to: sel) else { return nil } let imp = device.method(for: sel) let fn = unsafeBitCast(imp, to: IntBatteryGetter.self) let val = fn(device, sel) if val > 0 && val <= 100 { return val } return nil } let addr = model.address if isMulti { model.leftBattery = getBatteryValue(selectorName: "batteryPercentLeft") model.rightBattery = getBatteryValue(selectorName: "batteryPercentRight") model.caseBattery = getBatteryValue(selectorName: "batteryPercentCase") model.combinedBattery = getBatteryValue(selectorName: "batteryPercentCombined") model.singleBattery = getBatteryValue(selectorName: "batteryPercentSingle") } else { let single = getBatteryValue(selectorName: "batteryPercentSingle") ?? getBatteryValue(selectorName: "headsetBatteryPercent") ?? getBatteryValue(selectorName: "batteryLevel") ?? getBatteryValue(selectorName: "batteryPercentCombined") if let single = single { lastKnownBattery[addr] = single model.singleBattery = single } else if let cached = lastKnownBattery[addr] { // Keep smooth previous valid level during connection handshake model.singleBattery = cached } } } private func enrichFromIORegistry(devices: inout [BluetoothDeviceModel]) { let matching = IOServiceMatching("AppleDeviceManagementHIDEventService") var iterator: io_iterator_t = 0 guard IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS else { return } defer { IOObjectRelease(iterator) } var entry = IOIteratorNext(iterator) while entry != 0 { var props: Unmanaged? if IORegistryEntryCreateCFProperties(entry, &props, kCFAllocatorDefault, 0) == KERN_SUCCESS, let dict = props?.takeRetainedValue() as? [String: Any] { let prodName = dict["Product"] as? String ?? dict["DeviceName"] as? String let addr = dict["DeviceAddress"] as? String let batt = dict["BatteryPercent"] as? Int let battLeft = dict["BatteryPercentLeft"] as? Int let battRight = dict["BatteryPercentRight"] as? Int let battCase = dict["BatteryPercentCase"] as? Int for i in 0.. 0 && b <= 100 { if devices[i].singleBattery == nil { devices[i].singleBattery = b } } if devices[i].isMultiBattery { if let bl = battLeft, bl > 0 && bl <= 100 { if devices[i].leftBattery == nil { devices[i].leftBattery = bl } } if let br = battRight, br > 0 && br <= 100 { if devices[i].rightBattery == nil { devices[i].rightBattery = br } } if let bc = battCase, bc > 0 && bc <= 100 { if devices[i].caseBattery == nil { devices[i].caseBattery = bc } } } } } } IOObjectRelease(entry) entry = IOIteratorNext(iterator) } } }