bluetooth tracker work live stream
This commit is contained in:
parent
38043b33c8
commit
eb584ba22b
4 changed files with 277 additions and 41 deletions
35
Sources/battery_probe/main.swift
Normal file
35
Sources/battery_probe/main.swift
Normal 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()
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
build_app.sh
10
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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue