Compare commits
2 commits
Release0.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c907a24b56 | |||
| a11f6bcc38 |
4 changed files with 361 additions and 253 deletions
BIN
BLTS-Tracker.dmg
BIN
BLTS-Tracker.dmg
Binary file not shown.
|
|
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: "")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue