1 contributor
//
// 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))")
track("adding new USB Meter named '\(advertisedName)' with MAC Address: '\(macAddress)'")
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)
bluetoothDiscoveryLogger.notice("\(message, privacy: .public)")
}
private func shouldLogDiscoveryDetails(for identifier: UUID) -> Bool {
guard ProcessInfo.processInfo.environment["USB_METER_VERBOSE_LOGS"] == "1" 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;
track("\(central.state)")
for meter in appData.meters.values {
meter.btSerial.centralStateChanged(to: central.state)
}
switch central.state {
case .poweredOff:
scanStartedAt = nil
advertisementDataCache.clear()
track("Bluetooth is Off. How should I behave?")
case .poweredOn:
scanStartedAt = Date()
track("Bluetooth is On... Start scanning...")
// 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 reseting... . Whatever that means.")
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)
//track("Device discoverded UUID: '\(peripheral.identifier)' named '\(peripheral.name ?? "Unknown")'); RSSI: \(RSSI) dBm; Advertisment data: \(advertisementData)")
discoveredMeter(peripheral: peripheral, advertising: completeAdvertisementData, rssi: RSSI )
}
// MARK: CBCentralManager didConnect peripheral
internal func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
//track("Connected to peripheral: '\(peripheral.identifier)'")
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()
}
}