From eb584ba22b7d43e9a65a50c8081b0fdf16c64538 Mon Sep 17 00:00:00 2001 From: Autli Date: Tue, 6 Oct 2026 18:27:33 +0500 Subject: [PATCH] bluetooth tracker work live stream --- Sources/battery_probe/main.swift | 35 ++++ Sources/blts_tracker/BLTSTrackerApp.swift | 97 +++++++--- .../Services/BluetoothTracker.swift | 176 ++++++++++++++++-- build_app.sh | 10 +- 4 files changed, 277 insertions(+), 41 deletions(-) create mode 100644 Sources/battery_probe/main.swift diff --git a/Sources/battery_probe/main.swift b/Sources/battery_probe/main.swift new file mode 100644 index 0000000..afdbd72 --- /dev/null +++ b/Sources/battery_probe/main.swift @@ -0,0 +1,35 @@ +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)") + } + } + } +} + +runProbe() diff --git a/Sources/blts_tracker/BLTSTrackerApp.swift b/Sources/blts_tracker/BLTSTrackerApp.swift index 2185098..bdcc750 100644 --- a/Sources/blts_tracker/BLTSTrackerApp.swift +++ b/Sources/blts_tracker/BLTSTrackerApp.swift @@ -4,7 +4,7 @@ import IOKit import CoreAudio @main -final class AppDelegate: NSObject, NSApplicationDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private static var appDelegateInstance: AppDelegate? private var statusItem: NSStatusItem! @@ -13,6 +13,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var refreshTimer: Timer? static func main() { + if CommandLine.arguments.contains("--battery-probe") { + runBatteryProbe() + exit(0) + } + let app = NSApplication.shared let delegate = AppDelegate() // Strong reference to prevent ARC deallocation of weak NSApplication.delegate @@ -22,6 +27,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate { app.run() } + private static func runBatteryProbe() { + typealias IntGetter = @convention(c) (AnyObject, Selector) -> Int + let selSingle = Selector(("batteryPercentSingle")) + let selPeer = Selector(("peer")) + + if let paired = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] { + for dev in paired where dev.isConnected() { + let addr = dev.addressString ?? "" + var batt: Int? = nil + + 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 + } + } + + 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 + } + } + + if let b = batt { + print("\(addr):\(b)") + } + } + } + } + func applicationDidFinishLaunching(_ notification: Notification) { // Create native status bar item statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) @@ -31,7 +73,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { button.imagePosition = .imageLeft } - setupMenu() + let menu = NSMenu() + menu.delegate = self + statusItem.menu = menu // Listen to real-time tracker updates NotificationCenter.default.addObserver( @@ -65,7 +109,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func handleTrackerUpdate() { DispatchQueue.main.async { [weak self] in self?.updateStatusItem() - self?.setupMenu() } } @@ -74,6 +117,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if updater.isUpdating { button.image = NSImage(systemSymbolName: "arrow.triangle.2.circlepath", accessibilityDescription: "Updating") + button.imagePosition = .imageLeft let text = updater.progressText.isEmpty ? "Обновление" : updater.progressText button.title = " \(updater.progressVisual) \(text)" return @@ -82,23 +126,46 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Find best active device (either marked active or any connected device with battery data) let active = tracker.activeHeadphone ?? tracker.devices.first(where: { $0.isConnected && $0.primaryBatteryPercent != nil }) let isConn = active?.isConnected == true - let percent = active?.primaryBatteryPercent + let percent = tracker.currentBatteryPercent ?? active?.primaryBatteryPercent - button.image = NSImage(systemSymbolName: "headphones", accessibilityDescription: "Bluetooth Battery") + if button.image == nil || button.image?.accessibilityDescription != "Bluetooth Battery" { + button.image = NSImage(systemSymbolName: "headphones", accessibilityDescription: "Bluetooth Battery") + } + button.imagePosition = .imageLeft if isConn, let p = percent, p > 0 { button.title = " \(p)%" } else { button.title = "" } + + statusItem.length = NSStatusItem.variableLength + 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" + if let data = logLine.data(using: .utf8) { + if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: "/tmp/blts_live.log")) { + handle.seekToEndOfFile() + handle.write(data) + try? handle.close() + } else { + try? data.write(to: URL(fileURLWithPath: "/tmp/blts_live.log")) + } + } } - private func setupMenu() { - let menu = NSMenu() + func menuWillOpen(_ menu: NSMenu) { + buildMenu(menu) + } + + private func buildMenu(_ menu: NSMenu) { + menu.removeAllItems() let active = tracker.activeHeadphone ?? tracker.devices.first(where: { $0.isConnected && $0.primaryBatteryPercent != nil }) if let dev = active, dev.isConnected { - let percentStr = dev.primaryBatteryPercent != nil ? "\(dev.primaryBatteryPercent!)%" : "Подключено" + let batt = tracker.currentBatteryPercent ?? dev.primaryBatteryPercent + let percentStr = batt != nil ? "\(batt!)%" : "Подключено" let titleItem = NSMenuItem(title: "🎧 \(dev.name): \(percentStr)", action: nil, keyEquivalent: "") titleItem.isEnabled = false menu.addItem(titleItem) @@ -114,12 +181,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { menu.addItem(item) } - menu.addItem(NSMenuItem.separator()) - - let refreshItem = NSMenuItem(title: "🔄 Обновить заряд", action: #selector(refreshClicked), keyEquivalent: "r") - refreshItem.target = self - menu.addItem(refreshItem) - let updateTitle = updater.isUpdating ? "⏳ Обновление..." : "🌐 Обновить из GitHub" let updateItem = NSMenuItem(title: updateTitle, action: #selector(updateClicked), keyEquivalent: "u") updateItem.target = self @@ -131,14 +192,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let quitItem = NSMenuItem(title: "Завершить", action: #selector(quitClicked), keyEquivalent: "q") quitItem.target = self menu.addItem(quitItem) - - statusItem.menu = menu - } - - @objc private func refreshClicked() { - tracker.refreshNow() - updateStatusItem() - setupMenu() } @objc private func updateClicked() { diff --git a/Sources/blts_tracker/Services/BluetoothTracker.swift b/Sources/blts_tracker/Services/BluetoothTracker.swift index 8f96ae0..5aba58c 100644 --- a/Sources/blts_tracker/Services/BluetoothTracker.swift +++ b/Sources/blts_tracker/Services/BluetoothTracker.swift @@ -19,9 +19,33 @@ public final class BluetoothTracker: ObservableObject { private var timerSource: DispatchSourceTimer? 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 func syncFromServer(device: IOBluetoothDevice) { + let selUpdate = Selector(("updateFromServer")) + if device.responds(to: selUpdate) { + 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() { setupBluetoothListeners() startTracking() @@ -34,12 +58,14 @@ public final class BluetoothTracker: ObservableObject { public func startTracking() { stopTracking() updateDeviceList() + pollSubprocessProbe() // Kernel-level DispatchSourceTimer: strictly 1 second interval let timer = DispatchSource.makeTimerSource(flags: .strict, queue: DispatchQueue.main) timer.schedule(deadline: .now() + 1.0, repeating: 1.0, leeway: .milliseconds(50)) timer.setEventHandler { [weak self] in self?.updateDeviceList() + self?.pollSubprocessProbe() } timer.resume() self.timerSource = timer @@ -50,8 +76,94 @@ public final class BluetoothTracker: ObservableObject { timerSource = nil } + private var isProbeRunning = false + + public func pollSubprocessProbe() { + guard !isProbeRunning else { return } + + var probeBin: String? = nil + var probeArgs: [String] = [] + + if let exeDir = Bundle.main.executableURL?.deletingLastPathComponent().path { + let dedicated = "\(exeDir)/battery_probe" + if FileManager.default.isExecutableFile(atPath: dedicated) { + probeBin = dedicated + } + } + + if probeBin == nil, let exePath = Bundle.main.executablePath, FileManager.default.isExecutableFile(atPath: exePath) { + probeBin = exePath + probeArgs = ["--battery-probe"] + } + + guard let binary = probeBin else { return } + + isProbeRunning = true + DispatchQueue.global(qos: .utility).async { [weak self] in + defer { self?.isProbeRunning = false } + let proc = Process() + proc.executableURL = URL(fileURLWithPath: binary) + proc.arguments = probeArgs + let pipe = Pipe() + proc.standardOutput = pipe + do { + try proc.run() + 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) + } + } + } + } catch { + // Ignore probe errors + } + } + } + + 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 + } + } + } + + if let active = activeHeadphone, let fresh = probeResults[active.address], fresh > 0 && fresh <= 100 { + if activeHeadphone?.singleBattery != fresh { + activeHeadphone?.singleBattery = fresh + changed = true + } + if currentBatteryPercent != fresh { + currentBatteryPercent = fresh + changed = true + } + } + + if changed { + objectWillChange.send() + NotificationCenter.default.post(name: BluetoothTracker.didUpdateNotification, object: self) + } + } + public func refreshNow() { updateDeviceList() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.updateDeviceList() + } } private func setupBluetoothListeners() { @@ -71,6 +183,12 @@ public final class BluetoothTracker: ObservableObject { name: NSNotification.Name(name), object: nil ) + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleBluetoothChange), + name: NSNotification.Name(name), + object: nil + ) } } @@ -96,6 +214,12 @@ public final class BluetoothTracker: ObservableObject { let device = IOBluetoothDevice(addressString: address) ?? pairedDev let name = device.nameOrAddress ?? pairedDev.nameOrAddress ?? "Unknown Device" + // Actively sync latest attributes from bluetoothd + syncFromServer(device: device) + if device !== pairedDev { + syncFromServer(device: pairedDev) + } + let isAudioOutput = activeAudioName != nil && ( name.caseInsensitiveCompare(activeAudioName!) == .orderedSame || activeAudioName!.localizedCaseInsensitiveContains(name) || @@ -238,12 +362,27 @@ public final class BluetoothTracker: ObservableObject { 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)) - guard dev.responds(to: sel) else { return nil } - 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 + 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 } @@ -260,19 +399,20 @@ public final class BluetoothTracker: ObservableObject { model.rightBattery = query(selectorName: "batteryPercentRight") model.caseBattery = query(selectorName: "batteryPercentCase") model.combinedBattery = query(selectorName: "batteryPercentCombined") - model.singleBattery = query(selectorName: "batteryPercentSingle") + model.singleBattery = query(selectorName: "batteryPercentSingle") ?? query(selectorName: "headsetBattery") } else { - let single = query(selectorName: "batteryPercentSingle") - ?? query(selectorName: "headsetBatteryPercent") - ?? query(selectorName: "batteryLevel") - ?? query(selectorName: "batteryPercentCombined") - ?? query(selectorName: "batteryPercentLeft") - - if let single = single { - knownBatteries[addr] = single - model.singleBattery = single - } else if let known = knownBatteries[addr] { - model.singleBattery = known + 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 + } } } } diff --git a/build_app.sh b/build_app.sh index 4bbd995..b3e7fd5 100755 --- a/build_app.sh +++ b/build_app.sh @@ -33,6 +33,14 @@ if [ -f "$DIR/AppIcon.icns" ]; then cp "$DIR/AppIcon.icns" "$RESOURCES_DIR/AppIcon.icns" fi -chmod +x "$MACOS_DIR/blts_tracker" +swiftc \ + -module-cache-path "$MODULE_CACHE" \ + -O \ + -framework Foundation \ + -framework IOBluetooth \ + "$DIR/Sources/battery_probe/main.swift" \ + -o "$MACOS_DIR/battery_probe" + +chmod +x "$MACOS_DIR/blts_tracker" "$MACOS_DIR/battery_probe" echo "✅ Successfully built: $APP_DIR"