USB-Meter / USB Meter / Model / BluetoothManager.swift
1 contributor
329 lines | 14.906kb
//
//  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()
    }
}