From 7b9598861bc43405002a232639c197235004023b Mon Sep 17 00:00:00 2001 From: Serhiy Mytrovtsiy Date: Fri, 13 Aug 2021 11:33:04 +0300 Subject: [PATCH] feat: rewrite BLE reader. Improve battery levels for Apple BLE devices (Mouse/Keyboard/Trackpad) (#557) --- Kit/module/reader.swift | 3 +- Modules/Bluetooth/main.swift | 22 ++-- Modules/Bluetooth/popup.swift | 9 +- Modules/Bluetooth/readers.swift | 176 ++++++++++++++++++++------------ 4 files changed, 129 insertions(+), 81 deletions(-) diff --git a/Kit/module/reader.swift b/Kit/module/reader.swift index 5e2dc4fe..d8230ccf 100644 --- a/Kit/module/reader.swift +++ b/Kit/module/reader.swift @@ -45,7 +45,7 @@ public protocol ReaderInternal_p { func read() } -open class Reader: ReaderInternal_p { +open class Reader: NSObject, ReaderInternal_p { public var log: NextLog { return NextLog.shared.copy(category: "\(String(describing: self))") } @@ -70,6 +70,7 @@ open class Reader: ReaderInternal_p { public init(popup: Bool = false) { self.popup = popup + super.init() self.setup() debug("Successfully initialize reader", log: self.log) diff --git a/Modules/Bluetooth/main.swift b/Modules/Bluetooth/main.swift index 0d018d6e..3d5e07e9 100644 --- a/Modules/Bluetooth/main.swift +++ b/Modules/Bluetooth/main.swift @@ -13,19 +13,27 @@ import Foundation import Kit import CoreBluetooth +public enum BLEConnType: Int { + case ioDevice + case cache + case ble +} + public struct BLEDevice { - let uuid: UUID + let conn: BLEConnType + let address: String let name: String + var uuid: UUID? - var RSSI: Int? - var batteryLevel: [KeyValue_t] + var RSSI: Int? = nil + var batteryLevel: [KeyValue_t] = [] - var isConnected: Bool - var isPaired: Bool - var isInitialized: Bool + var isConnected: Bool = false + var isPaired: Bool = false var peripheral: CBPeripheral? + var isPeripheralConnected: Bool = false } public class Bluetooth: Module { @@ -79,7 +87,7 @@ public class Bluetooth: Module { let pair = self.selectedBattery.split(separator: "@") guard let device = value.first(where: { $0.name == pair.first! }) else { - error("cannot find selected battery: \(self.selectedBattery)") +// error("cannot find selected battery: \(self.selectedBattery)") return } diff --git a/Modules/Bluetooth/popup.swift b/Modules/Bluetooth/popup.swift index 5908581f..97e64f7a 100644 --- a/Modules/Bluetooth/popup.swift +++ b/Modules/Bluetooth/popup.swift @@ -32,12 +32,11 @@ internal class Popup: NSStackView, Popup_p { let views = self.subviews.filter{ $0 is BLEView }.map{ $0 as! BLEView } list.reversed().forEach { (ble: BLEDevice) in - if let view = views.first(where: { $0.uuid == ble.uuid }) { + if let view = views.first(where: { $0.address == ble.address }) { view.update(ble.batteryLevel) } else { self.addArrangedSubview(BLEView( width: self.frame.width, - uuid: ble.uuid, address: ble.address, name: ble.name, batteryLevel: ble.batteryLevel @@ -54,14 +53,14 @@ internal class Popup: NSStackView, Popup_p { } internal class BLEView: NSStackView { - public var uuid: UUID + public var address: String open override var intrinsicContentSize: CGSize { return CGSize(width: self.bounds.width, height: self.bounds.height) } - public init(width: CGFloat, uuid: UUID, address: String, name: String, batteryLevel: [KeyValue_t]) { - self.uuid = uuid + public init(width: CGFloat, address: String, name: String, batteryLevel: [KeyValue_t]) { + self.address = address super.init(frame: NSRect(x: 0, y: 0, width: width, height: 30)) diff --git a/Modules/Bluetooth/readers.swift b/Modules/Bluetooth/readers.swift index 8a733497..10581384 100644 --- a/Modules/Bluetooth/readers.swift +++ b/Modules/Bluetooth/readers.swift @@ -14,102 +14,100 @@ import Kit import CoreBluetooth import IOBluetooth -internal class DevicesReader: Reader<[BLEDevice]> { - private let ble: BluetoothDelegate = BluetoothDelegate() +internal class DevicesReader: Reader<[BLEDevice]>, CBCentralManagerDelegate, CBPeripheralDelegate { + private var devices: [BLEDevice] = [] - init() { - super.init() - } - - public override func read() { - self.ble.read() - self.callback(self.ble.devices) - } -} - -class BluetoothDelegate: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { private var manager: CBCentralManager! - + private var uuidAddress: [UUID: String] = [:] private var peripherals: [CBPeripheral] = [] - public var devices: [BLEDevice] = [] private var characteristicsDict: [UUID: CBCharacteristic] = [:] private let batteryServiceUUID = CBUUID(string: "0x180F") private let batteryCharacteristicsUUID = CBUUID(string: "0x2A19") - private let batteryKeys: [String] = [ - "BatteryPercent", - "BatteryPercentCase", - "BatteryPercentLeft", - "BatteryPercentRight" - ] - - override init() { + init() { super.init() self.manager = CBCentralManager.init(delegate: self, queue: nil) } - public func read() { - guard let dict = UserDefaults(suiteName: "/Library/Preferences/com.apple.Bluetooth") else { + public override func read() { + self.IODevices() + self.cacheDevices() + self.callback(self.devices) + } + + // MARK: - IODevices + + private func IODevices() { + guard var ioDevices = fetchIOService("AppleDeviceManagementHIDEventService") else { return } + ioDevices = ioDevices.filter{ $0.object(forKey: "BluetoothDevice") as? Bool == true } - IOBluetoothDevice.pairedDevices().forEach { (d) in - guard let device = d as? IOBluetoothDevice, device.isPaired() || device.isConnected(), - let cache = self.findInCache(dict, address: device.addressString) else { + ioDevices.forEach { (d: NSDictionary) in + guard let name = d.object(forKey: "Product") as? String, let batteryPercent = d.object(forKey: "BatteryPercent") as? Int else { return } - let rssi = device.rawRSSI() == 127 ? nil : Int(device.rawRSSI()) + var address: String = "" + if let addr = d.object(forKey: "DeviceAddress") as? String, addr != "" { + address = addr + } else if let addr = d.object(forKey: "SerialNumber") as? String, addr != "" { + address = addr + } else if let bleAddr = d.object(forKey: "BD_ADDR") as? Data, let addr = String(data: bleAddr, encoding: .utf8), addr != "" { + address = addr + } - if let idx = self.devices.firstIndex(where: { $0.uuid == cache.uuid }) { - self.devices[idx].RSSI = rssi - if !cache.batteryLevel.isEmpty { - self.devices[idx].batteryLevel = cache.batteryLevel - } - self.devices[idx].isConnected = device.isConnected() - self.devices[idx].isPaired = device.isPaired() + if let idx = self.devices.firstIndex(where: { $0.address == address && $0.conn == .ioDevice }) { + self.devices[idx].batteryLevel = [KeyValue_t(key: "battery", value: "\(batteryPercent)")] } else { self.devices.append(BLEDevice( - uuid: cache.uuid, - address: device.addressString, - name: device.nameOrAddress, - RSSI: rssi, - batteryLevel: cache.batteryLevel, - isConnected: device.isConnected(), - isPaired: device.isPaired(), - isInitialized: false + conn: .ioDevice, + address: address, + name: name, + batteryLevel: [KeyValue_t(key: "battery", value: "\(batteryPercent)")], + isConnected: true, + isPaired: true )) } } } - private func findInCache(_ cache: UserDefaults, address: String) -> (uuid: UUID, batteryLevel: [KeyValue_t])? { - guard let deviceCache = cache.object(forKey: "DeviceCache") as? [String: [String: Any]], + // MARK: - Cache + + private func cacheDevices() { + guard let cache = UserDefaults(suiteName: "/Library/Preferences/com.apple.Bluetooth"), + let deviceCache = cache.object(forKey: "DeviceCache") as? [String: [String: Any]], + let pairedDevices = cache.object(forKey: "PairedDevices") as? [String], let coreCache = cache.object(forKey: "CoreBluetoothCache") as? [String: [String: Any]] else { - return nil + return } - guard let uuid = coreCache.compactMap({ (key, dict) -> UUID? in + coreCache.forEach { (key: String, dict: [String: Any]) in guard let field = dict.first(where: { $0.key == "DeviceAddress" }), - let value = field.value as? String, - value == address else { - return nil + let value = field.value as? String else { + return + } + + if let uuid = UUID(uuidString: key) { + self.uuidAddress[uuid] = value } - return UUID(uuidString: key) - }).first else { - return nil } - var batteryLevel: [KeyValue_t] = [] - if let d = deviceCache.first(where: { $0.key == address }) { - for key in self.batteryKeys { - if let pair = d.value.first(where: { $0.key == key }) { + deviceCache.filter({ pairedDevices.contains($0.key) }).forEach { (address: String, dict: [String: Any]) in + if self.devices.filter({ $0.conn == .ioDevice || $0.conn == .ble }).contains(where: { $0.address == address }) { + return + } + + var batteryLevel: [KeyValue_t] = [] + + for key in ["BatteryPercent", "BatteryPercentCase", "BatteryPercentLeft", "BatteryPercentRight"] { + if let pair = dict.first(where: { $0.key == key }) { var percentage: Int = 0 switch pair.value { case let value as Int: percentage = value - if "\(pair.value)" == "1.00" { + if percentage == 1 { percentage *= 100 } case let value as Double: @@ -120,9 +118,34 @@ class BluetoothDelegate: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat batteryLevel.append(KeyValue_t(key: key, value: "\(percentage)")) } } + + if !batteryLevel.isEmpty { + let name = dict.first{ $0.key == "Name" }?.value as? String + + if let idx = self.devices.firstIndex(where: { $0.address == address && $0.conn == .cache }) { + self.devices[idx].batteryLevel = batteryLevel + + if let device: IOBluetoothDevice = IOBluetoothDevice.pairedDevices().first(where: { d in + guard let device = d as? IOBluetoothDevice, device.isPaired() || device.isConnected() else { + return false + } + return device.addressString == address + }) as? IOBluetoothDevice { + self.devices[idx].RSSI = device.rawRSSI() == 127 ? nil : Int(device.rawRSSI()) + self.devices[idx].isConnected = device.isConnected() + self.devices[idx].isPaired = device.isPaired() + } + } else { + self.devices.append(BLEDevice( + conn: .cache, + address: address, + name: name ?? "", + batteryLevel: batteryLevel, + isPaired: true + )) + } + } } - - return (uuid, batteryLevel) } // MARK: - CBCentralManagerDelegate @@ -136,24 +159,41 @@ class BluetoothDelegate: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { - guard let idx = self.devices.firstIndex(where: { $0.uuid == peripheral.identifier }) else { + guard let address = self.uuidAddress[peripheral.identifier] else { return } - if self.devices[idx].RSSI == nil { - self.devices[idx].RSSI = Int(truncating: RSSI) + guard let device: IOBluetoothDevice = IOBluetoothDevice.pairedDevices().first(where: { d in + guard let device = d as? IOBluetoothDevice, device.isPaired() || device.isConnected() else { + return false + } + return device.addressString == address + }) as? IOBluetoothDevice else { + return } - if self.devices[idx].peripheral == nil { - self.devices[idx].peripheral = peripheral + guard let idx = self.devices.firstIndex(where: { $0.address == address && $0.conn == .ble }) else { + self.devices.append(BLEDevice( + conn: .ble, + address: address, + name: peripheral.name ?? "Unknown", + uuid: peripheral.identifier, + RSSI: Int(truncating: RSSI), + peripheral: peripheral + )) + return } + self.devices[idx].RSSI = Int(truncating: RSSI) + self.devices[idx].isConnected = device.isConnected() + self.devices[idx].isPaired = device.isPaired() + if peripheral.state == .disconnected { central.connect(peripheral, options: nil) - } else if peripheral.state == .connected && !self.devices[idx].isInitialized { + } else if peripheral.state == .connected && !self.devices[idx].isPeripheralConnected { peripheral.delegate = self peripheral.discoverServices([batteryServiceUUID]) - self.devices[idx].isInitialized = true + self.devices[idx].isPeripheralConnected = true } }