bluetooth tracker work live stream

This commit is contained in:
Nikita 2026-10-06 18:27:33 +05:00
parent 38043b33c8
commit eb584ba22b
4 changed files with 277 additions and 41 deletions

View file

@ -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()

View file

@ -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() {

View file

@ -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..<devices.count {
if let fresh = probeResults[devices[i].address], fresh > 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
}
}
}
}

View file

@ -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"