Compare commits

..

2 commits

4 changed files with 361 additions and 253 deletions

Binary file not shown.

View file

@ -1,35 +1,54 @@
import Foundation import Foundation
import IOBluetooth import IOBluetooth
func runProbe() { let isPowerOn = (IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON)
guard let paired = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else { return } print("POWER:\(isPowerOn ? 1 : 0)")
for dev in paired where dev.isConnected() {
let addr = dev.addressString ?? ""
let sel = Selector(("batteryPercentSingle"))
if dev.responds(to: sel) {
let imp = dev.method(for: sel)
typealias IntFn = @convention(c) (AnyObject, Selector) -> Int
let fn = unsafeBitCast(imp, to: IntFn.self)
let val = fn(dev, sel)
if val > 0 && val <= 100 {
print("\(addr):\(val)")
continue
}
}
let selPeer = Selector(("peer")) guard isPowerOn else {
if dev.responds(to: selPeer), exit(0)
let peer = dev.perform(selPeer)?.takeUnretainedValue() as? NSObject,
peer.responds(to: sel) {
let imp = peer.method(for: sel)
typealias IntFn = @convention(c) (AnyObject, Selector) -> Int
let fn = unsafeBitCast(imp, to: IntFn.self)
let val = fn(peer, sel)
if val > 0 && val <= 100 {
print("\(addr):\(val)")
}
}
}
} }
runProbe() typealias IntFn = @convention(c) (AnyObject, Selector) -> Int
func getInt(_ target: AnyObject, _ selName: String) -> Int? {
let sel = Selector((selName))
if target.responds(to: sel) {
let imp = target.method(for: sel)
let fn = unsafeBitCast(imp, to: IntFn.self)
let v = fn(target, sel)
if v > 0 && v <= 100 { return v }
}
return nil
}
if let paired = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] {
let selPeer = Selector(("peer"))
for dev in paired {
let addr = dev.addressString ?? ""
let name = dev.nameOrAddress ?? ""
let connected = dev.isConnected()
var single = getInt(dev, "batteryPercentSingle") ?? getInt(dev, "headsetBattery")
var left = getInt(dev, "batteryPercentLeft")
var right = getInt(dev, "batteryPercentRight")
var bCase = getInt(dev, "batteryPercentCase")
var combined = getInt(dev, "batteryPercentCombined")
if dev.responds(to: selPeer),
let peer = dev.perform(selPeer)?.takeUnretainedValue() as? NSObject {
if single == nil { single = getInt(peer, "batteryPercentSingle") ?? getInt(peer, "headsetBattery") }
if left == nil { left = getInt(peer, "batteryPercentLeft") }
if right == nil { right = getInt(peer, "batteryPercentRight") }
if bCase == nil { bCase = getInt(peer, "batteryPercentCase") }
if combined == nil { combined = getInt(peer, "batteryPercentCombined") }
}
let sStr = single.map(String.init) ?? "-1"
let lStr = left.map(String.init) ?? "-1"
let rStr = right.map(String.init) ?? "-1"
let cStr = bCase.map(String.init) ?? "-1"
let combStr = combined.map(String.init) ?? "-1"
print("DEV:\(addr):\(name):\(connected ? 1 : 0):\(sStr):\(lStr):\(rStr):\(cStr):\(combStr)")
}
}

View file

@ -28,38 +28,51 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
} }
private static func runBatteryProbe() { private static func runBatteryProbe() {
let isPowerOn = (IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON)
print("POWER:\(isPowerOn ? 1 : 0)")
guard isPowerOn else { exit(0) }
typealias IntGetter = @convention(c) (AnyObject, Selector) -> Int typealias IntGetter = @convention(c) (AnyObject, Selector) -> Int
let selSingle = Selector(("batteryPercentSingle")) func getInt(_ target: AnyObject, _ selName: String) -> Int? {
let selPeer = Selector(("peer")) let sel = Selector((selName))
if target.responds(to: sel) {
let imp = target.method(for: sel)
let fn = unsafeBitCast(imp, to: IntGetter.self)
let v = fn(target, sel)
if v > 0 && v <= 100 { return v }
}
return nil
}
if let paired = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] { if let paired = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] {
for dev in paired where dev.isConnected() { let selPeer = Selector(("peer"))
for dev in paired {
let addr = dev.addressString ?? "" let addr = dev.addressString ?? ""
var batt: Int? = nil let name = dev.nameOrAddress ?? ""
let connected = dev.isConnected()
if dev.responds(to: selSingle) { var single = getInt(dev, "batteryPercentSingle") ?? getInt(dev, "headsetBattery")
let imp = dev.method(for: selSingle) var left = getInt(dev, "batteryPercentLeft")
let fn = unsafeBitCast(imp, to: IntGetter.self) var right = getInt(dev, "batteryPercentRight")
let val = fn(dev, selSingle) var bCase = getInt(dev, "batteryPercentCase")
if val > 0 && val <= 100 { var combined = getInt(dev, "batteryPercentCombined")
batt = val
} if dev.responds(to: selPeer),
let peer = dev.perform(selPeer)?.takeUnretainedValue() as? NSObject {
if single == nil { single = getInt(peer, "batteryPercentSingle") ?? getInt(peer, "headsetBattery") }
if left == nil { left = getInt(peer, "batteryPercentLeft") }
if right == nil { right = getInt(peer, "batteryPercentRight") }
if bCase == nil { bCase = getInt(peer, "batteryPercentCase") }
if combined == nil { combined = getInt(peer, "batteryPercentCombined") }
} }
if batt == nil, dev.responds(to: selPeer), let sStr = single.map(String.init) ?? "-1"
let peer = dev.perform(selPeer)?.takeUnretainedValue() as? NSObject, let lStr = left.map(String.init) ?? "-1"
peer.responds(to: selSingle) { let rStr = right.map(String.init) ?? "-1"
let imp = peer.method(for: selSingle) let cStr = bCase.map(String.init) ?? "-1"
let fn = unsafeBitCast(imp, to: IntGetter.self) let combStr = combined.map(String.init) ?? "-1"
let val = fn(peer, selSingle)
if val > 0 && val <= 100 {
batt = val
}
}
if let b = batt { print("DEV:\(addr):\(name):\(connected ? 1 : 0):\(sStr):\(lStr):\(rStr):\(cStr):\(combStr)")
print("\(addr):\(b)")
}
} }
} }
} }
@ -94,9 +107,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
// Start active tracking // Start active tracking
tracker.startTracking() tracker.startTracking()
// Direct main thread timer in AppDelegate for guaranteed 1-second UI refresh // Regular safety UI sync timer in AppDelegate
let t = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in let t = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.tracker.updateDeviceList()
self?.updateStatusItem() self?.updateStatusItem()
} }
RunLoop.main.add(t, forMode: .common) RunLoop.main.add(t, forMode: .common)
@ -123,9 +135,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
return return
} }
// Find best active device (either marked active or any connected device with battery data) let isPowerOn = tracker.isBluetoothPoweredOn
let active = tracker.activeHeadphone ?? tracker.devices.first(where: { $0.isConnected && $0.primaryBatteryPercent != nil }) let active = tracker.activeHeadphone ?? tracker.devices.first(where: { $0.isConnected && $0.primaryBatteryPercent != nil })
let isConn = active?.isConnected == true let isConn = isPowerOn && (active?.isConnected == true)
let percent = tracker.currentBatteryPercent ?? active?.primaryBatteryPercent let percent = tracker.currentBatteryPercent ?? active?.primaryBatteryPercent
if button.image == nil || button.image?.accessibilityDescription != "Bluetooth Battery" { if button.image == nil || button.image?.accessibilityDescription != "Bluetooth Battery" {
@ -136,6 +148,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
if isConn, let p = percent, p > 0 { if isConn, let p = percent, p > 0 {
button.title = " \(p)%" button.title = " \(p)%"
} else { } else {
// When Bluetooth is off or headphones disconnected: show ONLY the headphones icon
button.title = "" button.title = ""
} }
@ -143,7 +156,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
button.needsLayout = true button.needsLayout = true
button.needsDisplay = true button.needsDisplay = true
let logLine = "[\(Date())] Dev: \(active?.name ?? "none") | Conn: \(isConn) | Battery: \(percent.map(String.init) ?? "nil") | Title: '\(button.title)'\n" let logLine = "[\(Date())] Dev: \(active?.name ?? "none") | Conn: \(isConn) | Power: \(isPowerOn) | Battery: \(percent.map(String.init) ?? "nil") | Title: '\(button.title)'\n"
if let data = logLine.data(using: .utf8) { if let data = logLine.data(using: .utf8) {
if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: "/tmp/blts_live.log")) { if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: "/tmp/blts_live.log")) {
handle.seekToEndOfFile() handle.seekToEndOfFile()
@ -162,8 +175,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private func buildMenu(_ menu: NSMenu) { private func buildMenu(_ menu: NSMenu) {
menu.removeAllItems() menu.removeAllItems()
let isPowerOn = tracker.isBluetoothPoweredOn
let active = tracker.activeHeadphone ?? tracker.devices.first(where: { $0.isConnected && $0.primaryBatteryPercent != nil }) let active = tracker.activeHeadphone ?? tracker.devices.first(where: { $0.isConnected && $0.primaryBatteryPercent != nil })
if let dev = active, dev.isConnected {
if !isPowerOn {
let item = NSMenuItem(title: "⚠️ Bluetooth выключен", action: nil, keyEquivalent: "")
item.isEnabled = false
menu.addItem(item)
} else if let dev = active, dev.isConnected {
let batt = tracker.currentBatteryPercent ?? dev.primaryBatteryPercent let batt = tracker.currentBatteryPercent ?? dev.primaryBatteryPercent
let percentStr = batt != nil ? "\(batt!)%" : "Подключено" let percentStr = batt != nil ? "\(batt!)%" : "Подключено"
let titleItem = NSMenuItem(title: "🎧 \(dev.name): \(percentStr)", action: nil, keyEquivalent: "") let titleItem = NSMenuItem(title: "🎧 \(dev.name): \(percentStr)", action: nil, keyEquivalent: "")

View file

@ -15,38 +15,19 @@ public final class BluetoothTracker: ObservableObject {
@Published public var currentBatteryPercent: Int? @Published public var currentBatteryPercent: Int?
@Published public var currentDeviceName: String = "" @Published public var currentDeviceName: String = ""
@Published public var isConnected: Bool = false @Published public var isConnected: Bool = false
@Published public var isBluetoothPoweredOn: Bool = true
private var timerSource: DispatchSourceTimer? private var timerSource: DispatchSourceTimer?
private var knownBatteries: [String: Int] = [:] private var knownBatteries: [String: Int] = [:]
private typealias VoidFn = @convention(c) (AnyObject, Selector) -> Void
private typealias IntBatteryGetter = @convention(c) (AnyObject, Selector) -> Int
private typealias BoolGetter = @convention(c) (AnyObject, Selector) -> Bool private typealias BoolGetter = @convention(c) (AnyObject, Selector) -> Bool
private func syncFromServer(device: IOBluetoothDevice) { public static func checkBluetoothPower() -> Bool {
let selUpdate = Selector(("updateFromServer")) guard let controller = IOBluetoothHostController.default() else { return false }
if device.responds(to: selUpdate) { return controller.powerState == kBluetoothHCIPowerStateON
let imp = device.method(for: selUpdate)
let fn = unsafeBitCast(imp, to: VoidFn.self)
fn(device, selUpdate)
}
// Force CoreBluetooth coordinator to purge cached peers and query bluetoothd
if let cls = NSClassFromString("IOBluetoothCoreBluetoothCoordinator") as? NSObject.Type,
let coord = cls.perform(Selector(("sharedInstance")))?.takeUnretainedValue() as? NSObject,
let mgr = coord.perform(Selector(("classicManager")))?.takeUnretainedValue() as? NSObject {
let selOrphan = Selector(("orphanClassicPeers"))
if mgr.responds(to: selOrphan) {
_ = mgr.perform(selOrphan)
}
let selReq = Selector(("sendLocalDeviceStateRequest"))
if mgr.responds(to: selReq) {
_ = mgr.perform(selReq)
}
}
} }
public init() { public init() {
self.isBluetoothPoweredOn = BluetoothTracker.checkBluetoothPower()
setupBluetoothListeners() setupBluetoothListeners()
startTracking() startTracking()
} }
@ -60,12 +41,16 @@ public final class BluetoothTracker: ObservableObject {
updateDeviceList() updateDeviceList()
pollSubprocessProbe() pollSubprocessProbe()
// Kernel-level DispatchSourceTimer: strictly 1 second interval // Strict 1.0s timer for probe monitoring (with zero-flicker authoritative updates)
let timer = DispatchSource.makeTimerSource(flags: .strict, queue: DispatchQueue.main) let timer = DispatchSource.makeTimerSource(flags: .strict, queue: DispatchQueue.main)
timer.schedule(deadline: .now() + 1.0, repeating: 1.0, leeway: .milliseconds(50)) timer.schedule(deadline: .now() + 1.0, repeating: 1.0, leeway: .milliseconds(50))
timer.setEventHandler { [weak self] in timer.setEventHandler { [weak self] in
self?.updateDeviceList() guard let self = self else { return }
self?.pollSubprocessProbe() if !BluetoothTracker.checkBluetoothPower() {
self.applyPowerOffState()
} else {
self.pollSubprocessProbe()
}
} }
timer.resume() timer.resume()
self.timerSource = timer self.timerSource = timer
@ -78,28 +63,43 @@ public final class BluetoothTracker: ObservableObject {
private var isProbeRunning = false private var isProbeRunning = false
public func pollSubprocessProbe() { private func resolveProbeBinary() -> (String, [String])? {
guard !isProbeRunning else { return } // 1. Same directory as current executable
var probeBin: String? = nil
var probeArgs: [String] = []
if let exeDir = Bundle.main.executableURL?.deletingLastPathComponent().path { if let exeDir = Bundle.main.executableURL?.deletingLastPathComponent().path {
let dedicated = "\(exeDir)/battery_probe" let dedicated = "\(exeDir)/battery_probe"
if FileManager.default.isExecutableFile(atPath: dedicated) { if FileManager.default.isExecutableFile(atPath: dedicated) {
probeBin = dedicated return (dedicated, [])
} }
} }
if probeBin == nil, let exePath = Bundle.main.executablePath, FileManager.default.isExecutableFile(atPath: exePath) { // 2. App bundle path in project directory
probeBin = exePath let bundleProbe = "/Users/autli/Documents/blts_tracker/BLTS Tracker.app/Contents/MacOS/battery_probe"
probeArgs = ["--battery-probe"] if FileManager.default.isExecutableFile(atPath: bundleProbe) {
return (bundleProbe, [])
} }
guard let binary = probeBin else { return } // 3. Current executable only if named blts_tracker
if let exePath = Bundle.main.executablePath,
exePath.hasSuffix("blts_tracker"),
FileManager.default.isExecutableFile(atPath: exePath) {
return (exePath, ["--battery-probe"])
}
return nil
}
public func pollSubprocessProbe() {
guard !isProbeRunning else { return }
if !BluetoothTracker.checkBluetoothPower() {
applyPowerOffState()
return
}
guard let (binary, probeArgs) = resolveProbeBinary() else { return }
isProbeRunning = true isProbeRunning = true
DispatchQueue.global(qos: .utility).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
defer { self?.isProbeRunning = false } defer { self?.isProbeRunning = false }
let proc = Process() let proc = Process()
proc.executableURL = URL(fileURLWithPath: binary) proc.executableURL = URL(fileURLWithPath: binary)
@ -108,21 +108,10 @@ public final class BluetoothTracker: ObservableObject {
proc.standardOutput = pipe proc.standardOutput = pipe
do { do {
try proc.run() try proc.run()
proc.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile() let data = pipe.fileHandleForReading.readDataToEndOfFile()
proc.waitUntilExit()
if let output = String(data: data, encoding: .utf8), !output.isEmpty { if let output = String(data: data, encoding: .utf8), !output.isEmpty {
var parsed: [String: Int] = [:] self?.parseProbeOutput(output)
for line in output.components(separatedBy: .newlines) {
let parts = line.split(separator: ":")
if parts.count == 2, let b = Int(parts[1]) {
parsed[String(parts[0])] = b
}
}
if !parsed.isEmpty {
DispatchQueue.main.async {
self?.applyProbeBatteries(parsed)
}
}
} }
} catch { } catch {
// Ignore probe errors // Ignore probe errors
@ -130,28 +119,183 @@ public final class BluetoothTracker: ObservableObject {
} }
} }
private func applyProbeBatteries(_ probeResults: [String: Int]) { private struct ProbeDev {
var changed = false let address: String
for i in 0..<devices.count { let name: String
if let fresh = probeResults[devices[i].address], fresh > 0 && fresh <= 100 { let isConnected: Bool
if devices[i].singleBattery != fresh { let singleBattery: Int?
devices[i].singleBattery = fresh let leftBattery: Int?
knownBatteries[devices[i].address] = fresh let rightBattery: Int?
changed = true let caseBattery: Int?
let combinedBattery: Int?
}
private func parseProbeOutput(_ output: String) {
var powerOn: Bool = true
var devMap: [String: ProbeDev] = [:]
for line in output.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("POWER:") {
let val = trimmed.replacingOccurrences(of: "POWER:", with: "")
powerOn = (val == "1")
} else if trimmed.hasPrefix("DEV:") {
let parts = trimmed.split(separator: ":", omittingEmptySubsequences: false)
if parts.count >= 8 {
let addr = String(parts[1])
let name = String(parts[2])
let conn = parts[3] == "1"
let single = Int(parts[4]).flatMap { $0 > 0 && $0 <= 100 ? $0 : nil }
let left = Int(parts[5]).flatMap { $0 > 0 && $0 <= 100 ? $0 : nil }
let right = Int(parts[6]).flatMap { $0 > 0 && $0 <= 100 ? $0 : nil }
let bCase = Int(parts[7]).flatMap { $0 > 0 && $0 <= 100 ? $0 : nil }
let comb = (parts.count > 8 ? Int(parts[8]) : nil).flatMap { $0 > 0 && $0 <= 100 ? $0 : nil }
devMap[addr] = ProbeDev(
address: addr,
name: name,
isConnected: conn,
singleBattery: single,
leftBattery: left,
rightBattery: right,
caseBattery: bCase,
combinedBattery: comb
)
} }
} }
} }
if let active = activeHeadphone, let fresh = probeResults[active.address], fresh > 0 && fresh <= 100 { let applyBlock = { [weak self] in
if activeHeadphone?.singleBattery != fresh { guard let self = self else { return }
activeHeadphone?.singleBattery = fresh if !powerOn {
self.applyPowerOffState()
} else {
self.applyProbeDevices(devMap)
}
}
if Thread.isMainThread {
applyBlock()
} else {
DispatchQueue.main.async(execute: applyBlock)
}
}
private func applyPowerOffState() {
var changed = false
if self.isBluetoothPoweredOn {
self.isBluetoothPoweredOn = false
changed = true
}
if self.isConnected {
self.isConnected = false
changed = true
}
if self.currentBatteryPercent != nil {
self.currentBatteryPercent = nil
changed = true
}
if !self.currentDeviceName.isEmpty {
self.currentDeviceName = ""
changed = true
}
if self.activeHeadphone != nil {
self.activeHeadphone = nil
changed = true
}
self.knownBatteries.removeAll()
for i in 0..<self.devices.count {
if self.devices[i].isConnected {
self.devices[i].isConnected = false
changed = true changed = true
} }
if currentBatteryPercent != fresh { if self.devices[i].singleBattery != nil {
currentBatteryPercent = fresh self.devices[i].singleBattery = nil
changed = true changed = true
} }
} }
if changed {
self.objectWillChange.send()
NotificationCenter.default.post(name: BluetoothTracker.didUpdateNotification, object: self)
}
}
private func applyProbeDevices(_ devMap: [String: ProbeDev]) {
var changed = false
if !self.isBluetoothPoweredOn {
self.isBluetoothPoweredOn = true
changed = true
}
for i in 0..<devices.count {
let addr = devices[i].address
if let p = devMap[addr] {
if devices[i].isConnected != p.isConnected {
devices[i].isConnected = p.isConnected
changed = true
}
if p.isConnected {
if let fresh = p.singleBattery, fresh > 0 && fresh <= 100 {
if devices[i].singleBattery != fresh {
devices[i].singleBattery = fresh
knownBatteries[addr] = fresh
changed = true
}
}
if let l = p.leftBattery, devices[i].leftBattery != l {
devices[i].leftBattery = l
changed = true
}
if let r = p.rightBattery, devices[i].rightBattery != r {
devices[i].rightBattery = r
changed = true
}
if let c = p.caseBattery, devices[i].caseBattery != c {
devices[i].caseBattery = c
changed = true
}
if let comb = p.combinedBattery, devices[i].combinedBattery != comb {
devices[i].combinedBattery = comb
changed = true
}
} else {
knownBatteries.removeValue(forKey: addr)
if devices[i].singleBattery != nil {
devices[i].singleBattery = nil
changed = true
}
}
}
}
// Enrich connected devices from IOKit
enrichFromIORegistry(devices: &devices)
let foundActive = devices.first(where: { $0.isConnected && $0.isAudioDevice })
?? devices.first(where: { $0.isConnected })
if activeHeadphone?.address != foundActive?.address {
activeHeadphone = foundActive
changed = true
}
let newPercent = foundActive?.primaryBatteryPercent
if currentBatteryPercent != newPercent {
currentBatteryPercent = newPercent
changed = true
}
let newName = foundActive?.name ?? ""
if currentDeviceName != newName {
currentDeviceName = newName
changed = true
}
let newConn = foundActive?.isConnected == true
if isConnected != newConn {
isConnected = newConn
changed = true
}
if changed { if changed {
objectWillChange.send() objectWillChange.send()
@ -160,9 +304,11 @@ public final class BluetoothTracker: ObservableObject {
} }
public func refreshNow() { public func refreshNow() {
updateDeviceList() if !BluetoothTracker.checkBluetoothPower() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in applyPowerOffState()
self?.updateDeviceList() } else {
updateDeviceList()
pollSubprocessProbe()
} }
} }
@ -183,66 +329,57 @@ public final class BluetoothTracker: ObservableObject {
name: NSNotification.Name(name), name: NSNotification.Name(name),
object: nil object: nil
) )
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(handleBluetoothChange),
name: NSNotification.Name(name),
object: nil
)
} }
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(handleBluetoothChange),
name: NSNotification.Name("com.apple.Bluetooth.status"),
object: nil
)
} }
@objc private func handleBluetoothChange() { @objc private func handleBluetoothChange() {
DispatchQueue.main.async { [weak self] in refreshNow()
self?.updateDeviceList()
}
for delay in [0.2, 0.5, 1.0, 1.5, 2.0] {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.updateDeviceList()
}
}
} }
public func updateDeviceList() { public func updateDeviceList() {
var updatedList: [BluetoothDeviceModel] = [] let powerOn = BluetoothTracker.checkBluetoothPower()
let activeAudioName = getActiveCoreAudioDeviceName() self.isBluetoothPoweredOn = powerOn
// 1. Scan IOBluetooth paired devices if !powerOn {
applyPowerOffState()
return
}
var updatedList: [BluetoothDeviceModel] = []
// 1. Scan IOBluetooth paired devices catalogue
if let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] { if let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] {
for pairedDev in pairedDevices { for pairedDev in pairedDevices {
let address = pairedDev.addressString ?? UUID().uuidString let address = pairedDev.addressString ?? UUID().uuidString
let device = IOBluetoothDevice(addressString: address) ?? pairedDev let name = pairedDev.nameOrAddress ?? "Unknown Device"
let name = device.nameOrAddress ?? pairedDev.nameOrAddress ?? "Unknown Device" let majorClass = UInt32(pairedDev.deviceClassMajor)
let minorClass = UInt32(pairedDev.deviceClassMinor)
// Actively sync latest attributes from bluetoothd let isAudio = (majorClass == 4) || isLikelyHeadphones(name: name, minorClass: minorClass, majorClass: majorClass)
syncFromServer(device: device)
if device !== pairedDev {
syncFromServer(device: pairedDev)
}
let isAudioOutput = activeAudioName != nil && (
name.caseInsensitiveCompare(activeAudioName!) == .orderedSame ||
activeAudioName!.localizedCaseInsensitiveContains(name) ||
name.localizedCaseInsensitiveContains(activeAudioName!)
)
let isConnected = device.isConnected() || pairedDev.isConnected() || isAudioOutput
let majorClass = UInt32(device.deviceClassMajor)
let minorClass = UInt32(device.deviceClassMinor)
let isAudio = (majorClass == 4) || isLikelyHeadphones(name: name, minorClass: minorClass, majorClass: majorClass) || isAudioOutput
var isMulti = false var isMulti = false
let selMulti = Selector(("isMultiBatteryDevice")) let selMulti = Selector(("isMultiBatteryDevice"))
if device.responds(to: selMulti) { if pairedDev.responds(to: selMulti) {
let imp = device.method(for: selMulti) let imp = pairedDev.method(for: selMulti)
let fn = unsafeBitCast(imp, to: BoolGetter.self) let fn = unsafeBitCast(imp, to: BoolGetter.self)
isMulti = fn(device, selMulti) isMulti = fn(pairedDev, selMulti)
} }
// IMPORTANT: Strictly preserve isConnected and battery from the authoritative probe!
// NEVER query stale in-process IOBluetoothDevice ivars to avoid flickering!
let existing = self.devices.first(where: { $0.address == address })
let isConn = existing?.isConnected ?? false
var model = BluetoothDeviceModel( var model = BluetoothDeviceModel(
name: name, name: name,
address: address, address: address,
isConnected: isConnected, isConnected: isConn,
isAudioDevice: isAudio, isAudioDevice: isAudio,
isMultiBattery: isMulti, isMultiBattery: isMulti,
majorClass: majorClass, majorClass: majorClass,
@ -250,20 +387,19 @@ public final class BluetoothTracker: ObservableObject {
lastUpdated: Date() lastUpdated: Date()
) )
if isConnected { if isConn {
extractBatteryLevels(from: device, fallbackDevice: pairedDev, into: &model, isMulti: isMulti) model.singleBattery = existing?.singleBattery
} else { model.leftBattery = existing?.leftBattery
knownBatteries.removeValue(forKey: address) model.rightBattery = existing?.rightBattery
model.caseBattery = existing?.caseBattery
model.combinedBattery = existing?.combinedBattery
} }
updatedList.append(model) updatedList.append(model)
} }
} }
// 2. IOKit Registry enrich // 2. Sort: Connected audio first
enrichFromIORegistry(devices: &updatedList)
// 3. Sort: Connected audio first
updatedList.sort { d1, d2 in updatedList.sort { d1, d2 in
if d1.isConnected != d2.isConnected { if d1.isConnected != d2.isConnected {
return d1.isConnected && !d2.isConnected return d1.isConnected && !d2.isConnected
@ -274,29 +410,18 @@ public final class BluetoothTracker: ObservableObject {
return d1.name.localizedCaseInsensitiveCompare(d2.name) == .orderedAscending return d1.name.localizedCaseInsensitiveCompare(d2.name) == .orderedAscending
} }
self.devices = updatedList
let foundActive = updatedList.first(where: { $0.isConnected && $0.isAudioDevice }) let foundActive = updatedList.first(where: { $0.isConnected && $0.isAudioDevice })
?? updatedList.first(where: { $0.isConnected }) ?? updatedList.first(where: { $0.isConnected })
let newPercent = foundActive?.primaryBatteryPercent self.activeHeadphone = foundActive
let newName = foundActive?.name ?? "" self.currentBatteryPercent = foundActive?.primaryBatteryPercent
let newConnected = foundActive?.isConnected == true self.currentDeviceName = foundActive?.name ?? ""
self.isConnected = (foundActive?.isConnected == true)
let applyUpdate = { [weak self] in self.objectWillChange.send()
guard let self = self else { return } NotificationCenter.default.post(name: BluetoothTracker.didUpdateNotification, object: self)
self.objectWillChange.send()
self.devices = updatedList
self.activeHeadphone = foundActive
self.currentBatteryPercent = newPercent
self.currentDeviceName = newName
self.isConnected = newConnected
NotificationCenter.default.post(name: BluetoothTracker.didUpdateNotification, object: self)
}
if Thread.isMainThread {
applyUpdate()
} else {
DispatchQueue.main.async(execute: applyUpdate)
}
} }
public func getActiveCoreAudioDeviceName() -> String? { public func getActiveCoreAudioDeviceName() -> String? {
@ -359,64 +484,6 @@ public final class BluetoothTracker: ObservableObject {
return false return false
} }
private func extractBatteryLevels(from device: IOBluetoothDevice, fallbackDevice: IOBluetoothDevice? = nil, into model: inout BluetoothDeviceModel, isMulti: Bool) {
func getBatteryValue(from dev: IOBluetoothDevice, selectorName: String) -> Int? {
let sel = Selector((selectorName))
if dev.responds(to: sel) {
let imp = dev.method(for: sel)
let fn = unsafeBitCast(imp, to: IntBatteryGetter.self)
let val = fn(dev, sel)
if val > 0 && val <= 100 {
return val
}
}
let selPeer = Selector(("peer"))
if dev.responds(to: selPeer),
let peer = dev.perform(selPeer)?.takeUnretainedValue() as? NSObject {
let pSel = Selector((selectorName))
if peer.responds(to: pSel) {
let imp = peer.method(for: pSel)
let fn = unsafeBitCast(imp, to: IntBatteryGetter.self)
let val = fn(peer, pSel)
if val > 0 && val <= 100 {
return val
}
}
}
return nil
}
func query(selectorName: String) -> Int? {
return getBatteryValue(from: device, selectorName: selectorName)
?? (fallbackDevice != nil ? getBatteryValue(from: fallbackDevice!, selectorName: selectorName) : nil)
}
let addr = model.address
if isMulti {
model.leftBattery = query(selectorName: "batteryPercentLeft")
model.rightBattery = query(selectorName: "batteryPercentRight")
model.caseBattery = query(selectorName: "batteryPercentCase")
model.combinedBattery = query(selectorName: "batteryPercentCombined")
model.singleBattery = query(selectorName: "batteryPercentSingle") ?? query(selectorName: "headsetBattery")
} else {
if let probeBatt = knownBatteries[addr], probeBatt > 0 && probeBatt <= 100 {
model.singleBattery = probeBatt
} else {
let single = query(selectorName: "batteryPercentSingle")
?? query(selectorName: "headsetBattery")
?? query(selectorName: "batteryPercentCombined")
?? query(selectorName: "batteryPercentLeft")
?? query(selectorName: "batteryPercentRight")
if let single = single {
knownBatteries[addr] = single
model.singleBattery = single
}
}
}
}
private func enrichFromIORegistry(devices: inout [BluetoothDeviceModel]) { private func enrichFromIORegistry(devices: inout [BluetoothDeviceModel]) {
let matching = IOServiceMatching("AppleDeviceManagementHIDEventService") let matching = IOServiceMatching("AppleDeviceManagementHIDEventService")
var iterator: io_iterator_t = 0 var iterator: io_iterator_t = 0
@ -439,6 +506,9 @@ public final class BluetoothTracker: ObservableObject {
let battCase = dict["BatteryPercentCase"] as? Int let battCase = dict["BatteryPercentCase"] as? Int
for i in 0..<devices.count { for i in 0..<devices.count {
// Only enrich if the device is actually connected!
guard devices[i].isConnected else { continue }
let matchByName = prodName != nil && devices[i].name.caseInsensitiveCompare(prodName!) == .orderedSame let matchByName = prodName != nil && devices[i].name.caseInsensitiveCompare(prodName!) == .orderedSame
let matchByAddr = addr != nil && devices[i].address.replacingOccurrences(of: "-", with: ":").caseInsensitiveCompare(addr!.replacingOccurrences(of: "-", with: ":")) == .orderedSame let matchByAddr = addr != nil && devices[i].address.replacingOccurrences(of: "-", with: ":").caseInsensitiveCompare(addr!.replacingOccurrences(of: "-", with: ":")) == .orderedSame