// // CentralController.swift // CovidSafe // // Copyright © 2020 Australian Government. All rights reserved. // import Foundation import CoreData import CoreBluetooth import UIKit struct CentralWriteData: Codable { var modelC: String // phone model of central var rssi: Double var txPower: Double? var msg: String // tempID var org: String var v: Int } class CentralController: NSObject { enum CentralError: Error { case centralAlreadyOn case centralAlreadyOff } var centralDidUpdateStateCallback: ((CBManagerState) -> Void)? var characteristicDidReadValue: ((EncounterRecord) -> Void)? private let restoreIdentifierKey = "com.joelkek.tracer.central" private var central: CBCentralManager? private var recoveredPeripherals: [CBPeripheral] = [] private var cleanupPeripherals: [CBPeripheral] = [] private var queue: DispatchQueue // This dict is to keep track of discovered android devices, so that i do not connect to the same android device multiple times within the same BluetraceConfig.CentralScanInterval private var discoveredAndroidPeriManufacturerToUUIDMap = [Data: UUID]() // This dict has 2 purpose // 1. To store all the EncounterRecord, because the RSSI and TxPower is gotten at the didDiscoverPeripheral delegate, but the characterstic value is gotten at didUpdateValueForCharacteristic delegate // 2. Use to check for duplicated iphones peripheral being discovered, so that i dont connect to the same iphone again in the same scan window private var scannedPeripherals = [UUID: (peripheral: CBPeripheral, encounter: EncounterRecord)]() // stores the peripherals encountered within one scan interval var timerForScanning: Timer? public init(queue: DispatchQueue) { self.queue = queue super.init() } func turnOn() { DLog("CC requested to be turnOn") guard central == nil else { return } let options: [String: Any] = [CBCentralManagerOptionRestoreIdentifierKey: restoreIdentifierKey, CBCentralManagerOptionShowPowerAlertKey: NSNumber(true)] central = CBCentralManager(delegate: self, queue: self.queue, options: options ) } func turnOff() { DLog("CC turnOff") guard central != nil else { return } central?.stopScan() central = nil } func shouldRecordEncounter(_ encounter: EncounterRecord) -> Bool { guard let scannedDate = encounter.timestamp else { DLog("Not recorded encounter before \(encounter)") return true } if abs(scannedDate.timeIntervalSinceNow) > BluetraceConfig.CentralScanInterval { DLog("Encounter last recorded \(abs(scannedDate.timeIntervalSinceNow)) seconds ago") return true } return false } func shouldReconnectToPeripheral(peripheral: CBPeripheral) -> Bool { guard peripheral.state == .disconnected else { return false } guard let encounteredPeripheral = scannedPeripherals[peripheral.identifier] else { DLog("Not previously encountered CBPeripheral \(String(describing: peripheral.name))") return true } guard let scannedDate = encounteredPeripheral.encounter.timestamp else { DLog("Not previously recorded an encounter with \(encounteredPeripheral)") return true } if abs(scannedDate.timeIntervalSinceNow) > BluetraceConfig.CentralScanInterval { DLog("Peripheral last recorded \(abs(scannedDate.timeIntervalSinceNow)) seconds ago") return true } return false } public func getState() -> CBManagerState? { return central?.state } public func logPeripheralsCount(description: String) { #if DEBUG guard let peripherals = central?.retrieveConnectedPeripherals(withServices: [BluetraceConfig.BluetoothServiceID]) else { return } var connected = 0 var connecting = 0 var disconnected = 0 var disconnecting = 0 var unknown = 0 for peripheral in peripherals { switch peripheral.state { case .connecting: connecting+=1 case .connected: connected+=1 case .disconnected: disconnected+=1 case .disconnecting: disconnecting+=1 default: unknown+=1 } } let bleLogStr = "CC \(description) Current peripherals \nconnected: \(connected), \nconnecting: \(connecting), \ndisconnected: \(disconnected), \ndisconnecting: \(disconnecting), \nunknown: \(unknown), \nscannedPeripherals: \(scannedPeripherals.count)" let logRecord = BLELogRecord(message: bleLogStr) logRecord.saveToCoreData() #endif } } extension CentralController: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { DLog("CC willRestoreState. Central state: \(BluetraceUtils.centralStateToString(central.state))") recoveredPeripherals = [] if let peripheralsObject = dict[CBCentralManagerRestoredStatePeripheralsKey] { let peripherals = peripheralsObject as! Array DLog("CC restoring \(peripherals.count) peripherals from system.") logPeripheralsCount(description: "Restoring peripherals") for peripheral in peripherals { if peripheral.state == .connected { // only recover connected peripherals, dispose/disconnect otherwise. recoveredPeripherals.append(peripheral) peripheral.delegate = self } else { cleanupPeripherals.append(peripheral) } } logPeripheralsCount(description: "Done Restoring peripherals") } } func centralManagerDidUpdateState(_ central: CBCentralManager) { centralDidUpdateStateCallback?(central.state) switch central.state { case .poweredOn: DLog("CC Starting a scan") // for all peripherals that are not disconnected, disconnect them self.scannedPeripherals.forEach { (scannedPeri) in central.cancelPeripheralConnection(scannedPeri.value.peripheral) } // clear all peripherals, such that a new scan window can take place self.scannedPeripherals = [UUID: (CBPeripheral, EncounterRecord)]() self.discoveredAndroidPeriManufacturerToUUIDMap = [Data: UUID]() // handle a state restoration scenario for recoveredPeripheral in recoveredPeripherals { var restoredEncounter = EncounterRecord(rssi: 0, txPower: nil) restoredEncounter.timestamp = nil scannedPeripherals.updateValue((recoveredPeripheral, restoredEncounter), forKey: recoveredPeripheral.identifier) central.connect(recoveredPeripheral) } // cant cancel peripheral when BL OFF for cleanupPeripheral in cleanupPeripherals { central.cancelPeripheralConnection(cleanupPeripheral) } cleanupPeripherals = [] central.scanForPeripherals(withServices: [BluetraceConfig.BluetoothServiceID], options:nil) logPeripheralsCount(description: "Update state powerOn") default: DLog("State chnged to \(central.state)") } } func handlePeripheralOfUncertainStatus(_ peripheral: CBPeripheral) { // If not connected to Peripheral, attempt connection and exit if peripheral.state != .connected { DLog("CC handlePeripheralOfUncertainStatus not connected") central?.connect(peripheral) return } // If don't know about Peripheral's services, discover services and exit if peripheral.services == nil { DLog("CC handlePeripheralOfUncertainStatus unknown services") peripheral.discoverServices([BluetraceConfig.BluetoothServiceID]) return } // If Peripheral's services don't contain targetID, disconnect and remove, then exit. // If it does contain targetID, discover characteristics for service guard let service = peripheral.services?.first(where: { $0.uuid == BluetraceConfig.BluetoothServiceID }) else { DLog("CC handlePeripheralOfUncertainStatus no matching Services") central?.cancelPeripheralConnection(peripheral) return } DLog("CC handlePeripheralOfUncertainStatus discoverCharacteristics") peripheral.discoverCharacteristics([BluetraceConfig.BluetoothServiceID], for: service) // If Peripheral's service's characteristics don't contain targetID, disconnect and remove, then exit. // If it does contain targetID, read value for characteristic guard let characteristic = service.characteristics?.first(where: { $0.uuid == BluetraceConfig.BluetoothServiceID}) else { DLog("CC handlePeripheralOfUncertainStatus no matching Characteristics") central?.cancelPeripheralConnection(peripheral) return } DLog("CC handlePeripheralOfUncertainStatus readValue") peripheral.readValue(for: characteristic) return } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { let debugLogs = ["CentralState": BluetraceUtils.centralStateToString(central.state), "peripheral": peripheral, "advertisments": advertisementData as AnyObject] as AnyObject // dispatch in bluetrace queue queue.async { MessageAPI.getMessagesIfNeeded() { (messageResponse, error) in if let error = error { DLog("Get messages error: \(error.localizedDescription)") } // We currently dont do anything with the response. Messages are delivered via APN } } DLog("\(debugLogs)") var initialEncounter = EncounterRecord(rssi: RSSI.doubleValue, txPower: advertisementData[CBAdvertisementDataTxPowerLevelKey] as? Double) initialEncounter.timestamp = nil // iphones will "mask" the peripheral's identifier for android devices, resulting in the same android device being discovered multiple times with different peripheral identifier. Hence Android is using use CBAdvertisementDataServiceDataKey data for identifying an android pheripheral // Also, check that the length is greater than 2 to prevent crash. Otherwise ignore. if let manuData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, manuData.count > 2 { let androidIdentifierData = manuData.subdata(in: 2..")") } } else { DLog("Error: scannedPeripherals[peripheral.identifier] is \(String(describing: scannedPeripherals[peripheral.identifier])), characteristic.value is \(String(describing: characteristic.value))") } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { DLog("didWriteValueFor to peripheral: \(peripheral), for characteristics: \(characteristic). \(error != nil ? "error: \(error.debugDescription)" : "" )") central?.cancelPeripheralConnection(peripheral) } }