// // bluetoothSerial.swift // USB Meter // // Created by Bogdan Timofte on 17/03/2020. // Copyright © 2020 Bogdan Timofte. All rights reserved. // // https://github.com/hoiberg/HM10-BluetoothSerial-iOS import CoreBluetooth final class BluetoothSerial : NSObject, ObservableObject { enum AdministrativeState { case down case up } enum OperationalState: Int, Comparable { case peripheralNotConnected case peripheralConnectionPending case peripheralConnected case peripheralReady static func < (lhs: OperationalState, rhs: OperationalState) -> Bool { return lhs.rawValue < rhs.rawValue } } private var administrativeState = AdministrativeState.down private var operationalState = OperationalState.peripheralNotConnected { didSet { delegate?.opertionalStateChanged(to: operationalState) } } var macAddress: MACAddress private var manager: CBCentralManager private var radio: BluetoothRadio private(set) var rawRSSI: Int private var rssiSamples: [Int] = [] private let rssiAveragingWindow = 3 @Published private(set) var averageRSSI: Int private(set) var minRSSI: Int private(set) var maxRSSI: Int private var expectedResponseLength = 0 private var wdTimer: Timer? var peripheral: CBPeripheral /// The characteristic used for writes on the connected peripheral. private var writeCharacteristic: CBCharacteristic? /// The characteristic used for notifications on the connected peripheral. private var notifyCharacteristic: CBCharacteristic? private var buffer = Data() weak var delegate: SerialPortDelegate? init( peripheral: CBPeripheral, radio: BluetoothRadio, with macAddress: MACAddress, managedBy manager: CBCentralManager, RSSI: Int ) { self.peripheral = peripheral self.macAddress = macAddress self.radio = radio self.manager = manager self.rawRSSI = RSSI self.rssiSamples = [RSSI] self.averageRSSI = RSSI self.minRSSI = RSSI self.maxRSSI = RSSI Timer.scheduledTimer(withTimeInterval: 3, repeats: true, block: {_ in if peripheral.state == .connected { peripheral.readRSSI() } }) super.init() peripheral.delegate = self } func updateRSSI(_ value: Int) { rawRSSI = value rssiSamples.append(value) if rssiSamples.count > rssiAveragingWindow { rssiSamples.removeFirst() } let newAverage = rssiSamples.reduce(0, +) / rssiSamples.count minRSSI = Swift.min(minRSSI, newAverage) maxRSSI = Swift.max(maxRSSI, newAverage) if newAverage != averageRSSI { averageRSSI = newAverage } } private func resetCommunicationState(reason: String, clearCharacteristics: Bool) { if wdTimer != nil { track("Reset communication state (\(reason)) - invalidating watchdog") } wdTimer?.invalidate() wdTimer = nil if expectedResponseLength != 0 || !buffer.isEmpty { track("Reset communication state (\(reason)) - expected: \(expectedResponseLength), buffered: \(buffer.count)") } expectedResponseLength = 0 buffer.removeAll() if clearCharacteristics { writeCharacteristic = nil notifyCharacteristic = nil } } private func forceNotConnected(reason: String, clearCharacteristics: Bool = true) { resetCommunicationState(reason: reason, clearCharacteristics: clearCharacteristics) guard operationalState != .peripheralNotConnected else { return } operationalState = .peripheralNotConnected } func connect() { administrativeState = .up guard manager.state == .poweredOn else { track("Connect requested for '\(peripheral.identifier)' but central state is \(manager.state)") forceNotConnected(reason: "connect() while central is \(manager.state)") return } if operationalState < .peripheralConnected { resetCommunicationState(reason: "connect()", clearCharacteristics: true) operationalState = .peripheralConnectionPending track("Connect called for '\(peripheral.identifier)' while peripheral state is \(peripheral.state.rawValue)") manager.connect(peripheral, options: nil) } else { track("Peripheral allready connected: \(operationalState)") } } func disconnect() { administrativeState = .down resetCommunicationState(reason: "disconnect()", clearCharacteristics: true) if peripheral.state != .disconnected || operationalState != .peripheralNotConnected { track("Disconnect requested for '\(peripheral.identifier)' while peripheral state is \(peripheral.state.rawValue)") guard manager.state == .poweredOn else { track("Skipping central cancel for '\(peripheral.identifier)' because central state is \(manager.state)") forceNotConnected(reason: "disconnect() while central is \(manager.state)", clearCharacteristics: false) return } manager.cancelPeripheralConnection(peripheral) } } /** Send data - parameter data: Data to be sent. - parameter expectedResponseLength: Optional If message sent require a respnse the length for that response must be provideed. Incomming data will be buffered before calling delegate.didReceiveData */ func write(_ data: Data, expectedResponseLength: Int = 0) { //track("\(self.expectedResponseLength)") //track(data.hexEncodedStringValue) guard operationalState == .peripheralReady else { track("Guard: \(operationalState)") return } guard self.expectedResponseLength == 0 else { track("Guard: \(self.expectedResponseLength)") return } self.expectedResponseLength = expectedResponseLength // track("Sending...") guard let writeCharacteristic else { track("Missing write characteristic for \(radio)") self.expectedResponseLength = 0 return } let writeType: CBCharacteristicWriteType = writeCharacteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse peripheral.writeValue(data, for: writeCharacteristic, type: writeType) // track("Sent!") if self.expectedResponseLength != 0 { setWDT() } } func connectionEstablished () { resetCommunicationState(reason: "connectionEstablished()", clearCharacteristics: true) track("Connection established for '\(peripheral.identifier)'") rssiSamples = [rawRSSI] averageRSSI = rawRSSI minRSSI = rawRSSI maxRSSI = rawRSSI operationalState = .peripheralConnected peripheral.discoverServices(BluetoothRadioServicesUUIDS[radio]) } func connectionClosed () { track("Connection closed for '\(peripheral.identifier)' while peripheral state is \(peripheral.state.rawValue)") resetCommunicationState(reason: "connectionClosed()", clearCharacteristics: true) rssiSamples = [rawRSSI] averageRSSI = rawRSSI minRSSI = rawRSSI maxRSSI = rawRSSI operationalState = .peripheralNotConnected } func centralStateChanged(to newState: CBManagerState) { switch newState { case .poweredOn: if administrativeState == .up, operationalState == .peripheralNotConnected, peripheral.state == .disconnected { track("Central returned to poweredOn. Restoring connection to '\(peripheral.identifier)'") connect() } case .poweredOff, .resetting, .unauthorized, .unknown, .unsupported: if operationalState != .peripheralNotConnected || expectedResponseLength != 0 || !buffer.isEmpty { track("Central changed to \(newState). Forcing '\(peripheral.identifier)' to not connected.") } forceNotConnected(reason: "centralStateChanged(\(newState))") @unknown default: track("Central changed to an unknown state. Forcing '\(peripheral.identifier)' to not connected.") forceNotConnected(reason: "centralStateChanged(@unknown default)") } } func setWDT() { wdTimer?.invalidate() wdTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false, block: {_ in track("Response timeout. Expected: \(self.expectedResponseLength) - buffer: \(self.buffer.count)") self.expectedResponseLength = 0 self.disconnect() }) } private func refreshOperationalStateIfReady() { guard let notifyCharacteristic, let writeCharacteristic else { return } guard notifyCharacteristic.isNotifying else { track("Waiting for notifications on '\(notifyCharacteristic.uuid)' before marking peripheral ready") return } track("Peripheral ready with notify '\(notifyCharacteristic.uuid)' and write '\(writeCharacteristic.uuid)'") operationalState = .peripheralReady } private func updateBT18Characteristics(for service: CBService) { for characteristic in service.characteristics ?? [] { switch characteristic.uuid { case CBUUID(string: "FFE1"): if characteristic.properties.contains(.notify) || characteristic.properties.contains(.indicate) { peripheral.setNotifyValue(true, for: characteristic) notifyCharacteristic = characteristic } if writeCharacteristic == nil && (characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse)) { writeCharacteristic = characteristic } case CBUUID(string: "FFE2"): if characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse) { // DX-BT18 documents FFE2 as the preferred write-only endpoint when present. writeCharacteristic = characteristic } default: track ("Unexpected characteristic discovered: '\(characteristic)'") } } refreshOperationalStateIfReady() } private func updatePW0316Characteristics(for service: CBService) { for characteristic in service.characteristics ?? [] { switch characteristic.uuid { case CBUUID(string: "FFE9"): // TX from BLE side into UART writeCharacteristic = characteristic case CBUUID(string: "FFE4"): // RX notifications from UART side into BLE peripheral.setNotifyValue(true, for: characteristic) notifyCharacteristic = characteristic default: track ("Unexpected characteristic discovered: '\(characteristic)'") } } refreshOperationalStateIfReady() } } // MARK: CBPeripheralDelegate extension BluetoothSerial : CBPeripheralDelegate { // MARK: didReadRSSI func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { if error != nil { track( "Error: \(error!)" ) } updateRSSI(RSSI.intValue) } // MARK: didDiscoverServices func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { track("\(String(describing: peripheral.services))") if error != nil { track( "Error: \(error!)" ) } switch radio { case .BT18: for service in peripheral.services! { switch service.uuid { case CBUUID(string: "FFE0"): peripheral.discoverCharacteristics(Array(Set((BluetoothRadioNotifyUUIDs[radio] ?? []) + (BluetoothRadioWriteUUIDs[radio] ?? []))), for: service) default: track ("Unexpected service discovered: '\(service)'") } } case .PW0316: for service in peripheral.services! { switch service.uuid { case CBUUID(string: "FFE0"): peripheral.discoverCharacteristics(BluetoothRadioNotifyUUIDs[radio], for: service) case CBUUID(string: "FFE5"): peripheral.discoverCharacteristics(BluetoothRadioWriteUUIDs[radio], for: service) default: track ("Unexpected service discovered: '\(service)'") } } default: track("Radio \(radio) Not Implemented!") } } // MARK: didDiscoverCharacteristicsFor func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { if error != nil { track( "Error: \(error!)" ) } track("\(String(describing: service.characteristics))") switch radio { case .BT18: updateBT18Characteristics(for: service) case .PW0316: updatePW0316Characteristics(for: service) default: track("Radio \(radio) Not Implemented!") } } func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { if error != nil { track("Error updating notification state for '\(characteristic.uuid)': \(error!)") } track("Notification state updated for '\(characteristic.uuid)' - isNotifying: \(characteristic.isNotifying)") if characteristic.uuid == notifyCharacteristic?.uuid { refreshOperationalStateIfReady() } } // MARK: didUpdateValueFor func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { // track("") if error != nil { track( "Error: \(error!)" ) } let incomingData = characteristic.value ?? Data() guard !incomingData.isEmpty else { track("Received empty update for '\(characteristic.uuid)'") return } guard expectedResponseLength > 0 else { if !buffer.isEmpty { track("Dropping unsolicited data (\(incomingData.count) bytes) for '\(characteristic.uuid)' with residual buffer: \(buffer.count)") buffer.removeAll() } else { track("Dropping unsolicited data (\(incomingData.count) bytes) for '\(characteristic.uuid)' while no response is expected") } return } let previousBufferCount = buffer.count buffer.append(incomingData) // track("\n\(buffer.hexEncodedStringValue)") switch buffer.count { case let x where x < expectedResponseLength: setWDT() //track("buffering") break; case let x where x == expectedResponseLength: //track("buffer ready") wdTimer?.invalidate() expectedResponseLength = 0 delegate?.didReceiveData(buffer) buffer.removeAll() case let x where x > expectedResponseLength: // MARK: De unde stim că asta a fost tot? Probabil o deconectare ar rezolva problema let expectedLength = expectedResponseLength track("Buffer overflow for '\(characteristic.uuid)'. Chunk: \(incomingData.count), previous buffer: \(previousBufferCount), total: \(x), expected: \(expectedLength). Disconnecting to recover.") disconnect() default: track("This is not possible!") } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { if error != nil { track( "Error: \(error!)" ) } } func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { //track("") } } // MARK: SerialPortDelegate protocol SerialPortDelegate: AnyObject { // MARK: State Changed func opertionalStateChanged( to newOperationalState: BluetoothSerial.OperationalState ) // MARK: Data was received func didReceiveData(_ data: Data) } // MARK: SerialPortDelegate Optionals extension SerialPortDelegate { }