diff --git a/BLTS-Tracker.dmg b/BLTS-Tracker.dmg index 4f4f474..fb23c25 100644 Binary files a/BLTS-Tracker.dmg and b/BLTS-Tracker.dmg differ diff --git a/Sources/battery_probe/main.swift b/Sources/battery_probe/main.swift index afdbd72..b3292e7 100644 --- a/Sources/battery_probe/main.swift +++ b/Sources/battery_probe/main.swift @@ -1,35 +1,54 @@ import Foundation import IOBluetooth -func runProbe() { - guard let paired = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else { return } - 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")) - if dev.responds(to: selPeer), - 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)") - } - } - } +let isPowerOn = (IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON) +print("POWER:\(isPowerOn ? 1 : 0)") + +guard isPowerOn else { + exit(0) } -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)") + } +} diff --git a/Sources/blts_tracker/BLTSTrackerApp.swift b/Sources/blts_tracker/BLTSTrackerApp.swift index bdcc750..4a431d7 100644 --- a/Sources/blts_tracker/BLTSTrackerApp.swift +++ b/Sources/blts_tracker/BLTSTrackerApp.swift @@ -28,38 +28,51 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } 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 - let selSingle = Selector(("batteryPercentSingle")) - let selPeer = Selector(("peer")) + 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: IntGetter.self) + let v = fn(target, sel) + if v > 0 && v <= 100 { return v } + } + return nil + } 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 ?? "" - var batt: Int? = nil + let name = dev.nameOrAddress ?? "" + let connected = dev.isConnected() - if dev.responds(to: selSingle) { - let imp = dev.method(for: selSingle) - let fn = unsafeBitCast(imp, to: IntGetter.self) - let val = fn(dev, selSingle) - if val > 0 && val <= 100 { - batt = val - } + 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") } } - if batt == nil, dev.responds(to: selPeer), - let peer = dev.perform(selPeer)?.takeUnretainedValue() as? NSObject, - peer.responds(to: selSingle) { - let imp = peer.method(for: selSingle) - let fn = unsafeBitCast(imp, to: IntGetter.self) - let val = fn(peer, selSingle) - if val > 0 && val <= 100 { - batt = val - } - } + 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" - if let b = batt { - print("\(addr):\(b)") - } + print("DEV:\(addr):\(name):\(connected ? 1 : 0):\(sStr):\(lStr):\(rStr):\(cStr):\(combStr)") } } } @@ -94,8 +107,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // Start active tracking tracker.startTracking() - // Direct main thread timer in AppDelegate for guaranteed 1-second UI refresh - let t = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + // Fast 0.5s timer in AppDelegate + let t = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in self?.tracker.updateDeviceList() self?.updateStatusItem() } @@ -123,9 +136,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { 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 isConn = active?.isConnected == true + let isConn = isPowerOn && (active?.isConnected == true) let percent = tracker.currentBatteryPercent ?? active?.primaryBatteryPercent if button.image == nil || button.image?.accessibilityDescription != "Bluetooth Battery" { @@ -136,6 +149,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { if isConn, let p = percent, p > 0 { button.title = " \(p)%" } else { + // When Bluetooth is off or headphones disconnected: show ONLY the headphones icon button.title = "" } @@ -143,7 +157,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { button.needsLayout = 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 handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: "/tmp/blts_live.log")) { handle.seekToEndOfFile() @@ -162,8 +176,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private func buildMenu(_ menu: NSMenu) { menu.removeAllItems() + let isPowerOn = tracker.isBluetoothPoweredOn 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 percentStr = batt != nil ? "\(batt!)%" : "Подключено" let titleItem = NSMenuItem(title: "🎧 \(dev.name): \(percentStr)", action: nil, keyEquivalent: "") diff --git a/Sources/blts_tracker/Services/BluetoothTracker.swift b/Sources/blts_tracker/Services/BluetoothTracker.swift index 5aba58c..bbae8cb 100644 --- a/Sources/blts_tracker/Services/BluetoothTracker.swift +++ b/Sources/blts_tracker/Services/BluetoothTracker.swift @@ -15,6 +15,7 @@ public final class BluetoothTracker: ObservableObject { @Published public var currentBatteryPercent: Int? @Published public var currentDeviceName: String = "" @Published public var isConnected: Bool = false + @Published public var isBluetoothPoweredOn: Bool = true private var timerSource: DispatchSourceTimer? private var knownBatteries: [String: Int] = [:] @@ -23,6 +24,11 @@ public final class BluetoothTracker: ObservableObject { private typealias IntBatteryGetter = @convention(c) (AnyObject, Selector) -> Int private typealias BoolGetter = @convention(c) (AnyObject, Selector) -> Bool + public static func checkBluetoothPower() -> Bool { + guard let controller = IOBluetoothHostController.default() else { return false } + return controller.powerState == kBluetoothHCIPowerStateON + } + private func syncFromServer(device: IOBluetoothDevice) { let selUpdate = Selector(("updateFromServer")) if device.responds(to: selUpdate) { @@ -47,7 +53,9 @@ public final class BluetoothTracker: ObservableObject { } public init() { + self.isBluetoothPoweredOn = BluetoothTracker.checkBluetoothPower() setupBluetoothListeners() + setupAudioListeners() startTracking() } @@ -60,9 +68,9 @@ public final class BluetoothTracker: ObservableObject { updateDeviceList() pollSubprocessProbe() - // Kernel-level DispatchSourceTimer: strictly 1 second interval + // Strict 500ms DispatchSourceTimer for instant real-time reactivity 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() + 0.5, repeating: 0.5, leeway: .milliseconds(50)) timer.setEventHandler { [weak self] in self?.updateDeviceList() self?.pollSubprocessProbe() @@ -81,6 +89,14 @@ public final class BluetoothTracker: ObservableObject { public func pollSubprocessProbe() { guard !isProbeRunning else { return } + // Instant check: if Bluetooth is off, no need to run probe + if !BluetoothTracker.checkBluetoothPower() { + if isBluetoothPoweredOn || isConnected || currentBatteryPercent != nil { + applyPowerOffState() + } + return + } + var probeBin: String? = nil var probeArgs: [String] = [] @@ -99,7 +115,7 @@ public final class BluetoothTracker: ObservableObject { guard let binary = probeBin else { return } isProbeRunning = true - DispatchQueue.global(qos: .utility).async { [weak self] in + DispatchQueue.global(qos: .userInitiated).async { [weak self] in defer { self?.isProbeRunning = false } let proc = Process() proc.executableURL = URL(fileURLWithPath: binary) @@ -111,18 +127,7 @@ public final class BluetoothTracker: ObservableObject { proc.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() if let output = String(data: data, encoding: .utf8), !output.isEmpty { - var parsed: [String: Int] = [:] - 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) - } - } + self?.parseProbeOutput(output) } } catch { // Ignore probe errors @@ -130,28 +135,174 @@ public final class BluetoothTracker: ObservableObject { } } - private func applyProbeBatteries(_ probeResults: [String: Int]) { - var changed = false - for i in 0.. 0 && fresh <= 100 { - if devices[i].singleBattery != fresh { - devices[i].singleBattery = fresh - knownBatteries[devices[i].address] = fresh - changed = true + private struct ProbeDev { + let address: String + let name: String + let isConnected: Bool + let singleBattery: Int? + let leftBattery: Int? + let rightBattery: Int? + 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 { - if activeHeadphone?.singleBattery != fresh { - activeHeadphone?.singleBattery = fresh + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if !powerOn { + self.applyPowerOffState() + } else { + self.applyProbeDevices(devMap) + } + } + } + + 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.. 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 + } + } + } + } + + 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 { objectWillChange.send() @@ -161,9 +312,7 @@ public final class BluetoothTracker: ObservableObject { public func refreshNow() { updateDeviceList() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - self?.updateDeviceList() - } + pollSubprocessProbe() } private func setupBluetoothListeners() { @@ -173,7 +322,9 @@ public final class BluetoothTracker: ObservableObject { "IOBluetoothDeviceNotification", "IOBluetoothDeviceServicesResolvedNotification", "IOBluetoothDeviceNameChangedNotification", - "IOBluetoothHandsFreeDeviceDidUpdateNotification" + "IOBluetoothHandsFreeDeviceDidUpdateNotification", + "com.apple.Bluetooth.status", + "com.apple.bluetooth.status" ] for name in notificationNames { @@ -192,18 +343,41 @@ public final class BluetoothTracker: ObservableObject { } } + private func setupAudioListeners() { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + AudioObjectAddPropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), + &address, + DispatchQueue.main + ) { [weak self] _, _ in + self?.refreshNow() + } + } + @objc private func handleBluetoothChange() { DispatchQueue.main.async { [weak self] in - self?.updateDeviceList() + self?.refreshNow() } - for delay in [0.2, 0.5, 1.0, 1.5, 2.0] { + for delay in [0.2, 0.5, 1.0] { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.updateDeviceList() + self?.refreshNow() } } } public func updateDeviceList() { + let powerOn = BluetoothTracker.checkBluetoothPower() + self.isBluetoothPoweredOn = powerOn + + if !powerOn { + applyPowerOffState() + return + } + var updatedList: [BluetoothDeviceModel] = [] let activeAudioName = getActiveCoreAudioDeviceName() @@ -225,7 +399,7 @@ public final class BluetoothTracker: ObservableObject { activeAudioName!.localizedCaseInsensitiveContains(name) || name.localizedCaseInsensitiveContains(activeAudioName!) ) - let isConnected = device.isConnected() || pairedDev.isConnected() || isAudioOutput + let isConnected = (device.isConnected() || pairedDev.isConnected()) let majorClass = UInt32(device.deviceClassMajor) let minorClass = UInt32(device.deviceClassMinor)