// // BTManager.swift // USB Meter // // Created by Bogdan Timofte on 01/03/2020. // Copyright © 2020 Bogdan Timofte. All rights reserved. // import CoreBluetooth import OSLog private let bluetoothDiscoveryLogger = Logger(subsystem: "ro.xdev.USB-Meter", category: "BluetoothDiscovery") class BluetoothManager : NSObject, ObservableObject { private var manager: CBCentralManager? private var isStarting = false private var advertisementDataCache = AdvertisementDataCache() private var lastDiscoveryLog = [String: Date]() @Published var managerState = CBManagerState.unknown @Published private(set) var scanStartedAt: Date? override init () { super.init() } func start() { guard manager == nil, !isStarting else { return } isStarting = true track("Starting Bluetooth manager and requesting authorization if needed") DispatchQueue.main.async { [weak self] in guard let self else { return } defer { self.isStarting = false } guard self.manager == nil else { return } self.manager = CBCentralManager(delegate: self, queue: nil) } } private func scanForMeters() { guard let manager else { track("Scan requested before Bluetooth manager was started") return } guard manager.state == .poweredOn else { track( "Scan requested but Bluetooth state is \(manager.state)") return } //manager.scanForPeripherals(withServices: allBluetoothRadioServices(), options: [ CBCentralManagerScanOptionAllowDuplicatesKey: true ]) let serviceUUIDs = allBluetoothRadioServices() track("Scanning for USB meters with services: \(serviceUUIDs.map(\.uuidString).joined(separator: ", "))") manager.scanForPeripherals(withServices: serviceUUIDs, options: [ CBCentralManagerScanOptionAllowDuplicatesKey: true ]) } func discoveredMeter(peripheral: CBPeripheral, advertising advertismentData: [String : Any], rssi RSSI: NSNumber) { logDiscoveryCandidate(peripheral: peripheral, advertising: advertismentData, rssi: RSSI) let peripheralName = resolvedPeripheralName(for: peripheral, advertising: advertismentData) guard let match = resolvedModel(for: peripheralName, advertising: advertismentData) else { let reason: String if let peripheralName { reason = "unrecognized peripheral name '\(peripheralName)'; known names: \(Model.knownPeripheralNames.joined(separator: ", "))" } else { reason = "missing peripheral name/local name" } logDiscoveryRejection( peripheral: peripheral, reason: reason, advertising: advertismentData, rssi: RSSI ) return } let model = match.model let radio = match.radio let advertisedName = match.advertisedName guard let macAddress = resolvedMACAddress(from: advertismentData) else { logDiscoveryRejection( peripheral: peripheral, reason: "missing or short manufacturer data for '\(advertisedName)'", advertising: advertismentData, rssi: RSSI ) return } let macAddressString = macAddress.description appData.registerMeter(macAddress: macAddressString, modelName: model.canonicalName, advertisedName: advertisedName) appData.noteMeterSeen(at: Date(), macAddress: macAddressString) if appData.meters[peripheral.identifier] == nil { logDiscovery("BLE discovery accepted: model='\(model.canonicalName)', radio='\(radio)', advertisedName='\(advertisedName)', match='\(match.reason)', macAddress='\(macAddressString)'. \(discoveryDescription(peripheral: peripheral, advertising: advertismentData, rssi: RSSI))") let btSerial = BluetoothSerial(peripheral: peripheral, radio: radio, with: macAddress, managedBy: manager!, RSSI: RSSI.intValue) var m = appData.meters let meter = Meter(model: model, with: btSerial) m[peripheral.identifier] = meter appData.meters = m appData.restoreChargeMonitoringStateIfNeeded(for: meter) } else if let meter = appData.meters[peripheral.identifier] { meter.lastSeen = Date() meter.btSerial.updateRSSI(RSSI.intValue) let macAddress = meter.btSerial.macAddress.description if meter.name == macAddress, let syncedName = appData.meterName(for: macAddress), syncedName != macAddress { meter.updateNameFromStore(syncedName) } if peripheral.delegate == nil { peripheral.delegate = meter.btSerial } } } private func resolvedModel(for peripheralName: String?, advertising advertismentData: [String: Any]) -> (model: Model, advertisedName: String, radio: BluetoothRadio, reason: String)? { if let peripheralName { if let model = Model.model(forPeripheralName: peripheralName) { return (model, peripheralName, radio(for: model, peripheralName: peripheralName), "recognized peripheral name") } } return nil } private func radio(for model: Model, peripheralName: String) -> BluetoothRadio { guard model == .TC66C else { return model.radio } if peripheralName.caseInsensitiveCompare("BT24-M") == .orderedSame { return .BT24M } return model.radio } private func resolvedMACAddress(from advertismentData: [String: Any]) -> MACAddress? { guard let manufacturerData = resolvedManufacturerData(from: advertismentData), manufacturerData.count > 2 else { return nil } return MACAddress(from: manufacturerData.suffix(from: 2)) } private func logDiscoveryCandidate(peripheral: CBPeripheral, advertising advertismentData: [String: Any], rssi RSSI: NSNumber) { guard shouldLogDiscoveryDetails(for: peripheral.identifier) else { return } logDiscovery("BLE discovery candidate: \(discoveryDescription(peripheral: peripheral, advertising: advertismentData, rssi: RSSI))") } private func logDiscoveryRejection( peripheral: CBPeripheral, reason: String, advertising advertismentData: [String: Any], rssi RSSI: NSNumber ) { guard shouldLogDiscoveryRejection(for: peripheral.identifier, reason: reason) else { return } logDiscovery("BLE discovery rejected: \(reason). \(discoveryDescription(peripheral: peripheral, advertising: advertismentData, rssi: RSSI))") } private func logDiscovery(_ message: String) { track(message) guard debugLogFlagEnabled("USB_METER_BLUETOOTH_LOGS") else { return } bluetoothDiscoveryLogger.debug("\(message, privacy: .public)") } private func shouldLogDiscoveryDetails(for identifier: UUID) -> Bool { guard debugLogFlagEnabled("USB_METER_BLUETOOTH_LOGS") else { return false } return shouldLogDiscoveryDetails(for: identifier.uuidString) } private func shouldLogDiscoveryRejection(for identifier: UUID, reason: String) -> Bool { shouldLogDiscoveryDetails(for: "\(identifier.uuidString):\(reason)") } private func shouldLogDiscoveryDetails(for key: String) -> Bool { let now = Date() if let lastLoggedAt = lastDiscoveryLog[key], now.timeIntervalSince(lastLoggedAt) < 5 { return false } lastDiscoveryLog[key] = now return true } private func discoveryDescription(peripheral: CBPeripheral, advertising advertismentData: [String: Any], rssi RSSI: NSNumber) -> String { let localName = advertismentData[CBAdvertisementDataLocalNameKey] as? String let services = serviceUUIDs(from: advertismentData, key: CBAdvertisementDataServiceUUIDsKey).map(\.uuidString).joined(separator: ", ") let overflowServices = serviceUUIDs(from: advertismentData, key: CBAdvertisementDataOverflowServiceUUIDsKey).map(\.uuidString).joined(separator: ", ") let solicitedServices = serviceUUIDs(from: advertismentData, key: CBAdvertisementDataSolicitedServiceUUIDsKey).map(\.uuidString).joined(separator: ", ") let manufacturerData = resolvedManufacturerData(from: advertismentData) let manufacturerSummary = manufacturerData.map { "\($0.count)b \($0.hexEncodedStringValue)" } ?? "nil" let txPower = advertismentData[CBAdvertisementDataTxPowerLevelKey].map { "\($0)" } ?? "nil" let connectable = advertismentData[CBAdvertisementDataIsConnectable].map { "\($0)" } ?? "nil" return "id='\(peripheral.identifier)', peripheralName='\(peripheral.name ?? "nil")', localName='\(localName ?? "nil")', resolvedName='\(resolvedPeripheralName(for: peripheral, advertising: advertismentData) ?? "nil")', rssi=\(RSSI), connectable=\(connectable), txPower=\(txPower), services=[\(services)], overflowServices=[\(overflowServices)], solicitedServices=[\(solicitedServices)], manufacturerData=\(manufacturerSummary)" } private func resolvedPeripheralName(for peripheral: CBPeripheral, advertising advertismentData: [String : Any]) -> String? { let candidates = [ (advertismentData[CBAdvertisementDataLocalNameKey] as? String), peripheral.name ] for candidate in candidates { if let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty { return trimmed } } return nil } private func serviceUUIDs(from advertismentData: [String : Any], key: String) -> [CBUUID] { advertismentData[key] as? [CBUUID] ?? [] } private func resolvedManufacturerData(from advertismentData: [String : Any]) -> Data? { if let data = advertismentData[CBAdvertisementDataManufacturerDataKey] as? Data { return data } if let data = advertismentData["kCBAdvDataManufacturerData"] as? Data { return data } return nil } } extension BluetoothManager : CBCentralManagerDelegate { // MARK: CBCentralManager state Changed func centralManagerDidUpdateState(_ central: CBCentralManager) { managerState = central.state; for meter in appData.meters.values { meter.btSerial.centralStateChanged(to: central.state) } switch central.state { case .poweredOff: scanStartedAt = nil advertisementDataCache.clear() case .poweredOn: scanStartedAt = Date() // note that "didDisconnectPeripheral" won't be called if BLE is turned off while connected // connectedPeripheral = nil // pendingPeripheral = nil scanForMeters() case .resetting: scanStartedAt = nil advertisementDataCache.clear() track("Bluetooth is resetting.") case .unauthorized: scanStartedAt = nil advertisementDataCache.clear() track("Bluetooth is not authorized.") case .unknown: scanStartedAt = nil advertisementDataCache.clear() track("Bluetooth is in an unknown state.") case .unsupported: scanStartedAt = nil advertisementDataCache.clear() track("Bluetooth not supported by device") default: scanStartedAt = nil advertisementDataCache.clear() track("Bluetooth is in a state never seen before!") } } // MARK: CBCentralManager didDiscover peripheral func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { let completeAdvertisementData = self.advertisementDataCache.append(peripheral: peripheral, advertisementData: advertisementData) discoveredMeter(peripheral: peripheral, advertising: completeAdvertisementData, rssi: RSSI ) } // MARK: CBCentralManager didConnect peripheral internal func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { if let usbMeter = appData.meters[peripheral.identifier] { usbMeter.btSerial.connectionEstablished() } else { track("Connected to meter with UUID: '\(peripheral.identifier)'") } } // MARK: CBCentralManager didDisconnectPeripheral peripheral internal func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { track("Disconnected from peripheral: '\(peripheral.identifier)' with error: \(error.debugDescription)") if let usbMeter = appData.meters[peripheral.identifier] { usbMeter.btSerial.connectionClosed() } else { track("Disconnected from meter with UUID: '\(peripheral.identifier)'") } } internal func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { track("Failed to connect to peripheral: '\(peripheral.identifier)' with error: \(error.debugDescription)") if let usbMeter = appData.meters[peripheral.identifier] { usbMeter.btSerial.connectionClosed() } else { track("Failed to connect to meter with UUID: '\(peripheral.identifier)'") } } } private class AdvertisementDataCache { private var map = [UUID: [String: Any]]() func append(peripheral: CBPeripheral, advertisementData: [String: Any]) -> [String: Any] { var ad = (map[peripheral.identifier]) ?? [String: Any]() if let localName = peripheral.name?.trimmingCharacters(in: .whitespacesAndNewlines), !localName.isEmpty { ad[CBAdvertisementDataLocalNameKey] = localName } for (key, value) in advertisementData { ad[key] = value } map[peripheral.identifier] = ad return ad } func clear() { map.removeAll() } }