USB-Meter / USB Meter / Model / BluetoothSerial.swift
Newer Older
412 lines | 16.497kb
Bogdan Timofte authored 2 months ago
1
//
2
//  bluetoothSerial.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 17/03/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8
//  https://github.com/hoiberg/HM10-BluetoothSerial-iOS
9
import CoreBluetooth
10

            
11
final class BluetoothSerial : NSObject, ObservableObject {
12

            
13
    enum AdministrativeState {
14
        case down
15
        case up
16
    }
17
    enum OperationalState: Int, Comparable {
18
        case peripheralNotConnected
19
        case peripheralConnectionPending
20
        case peripheralConnected
21
        case peripheralReady
22

            
23
        static func < (lhs: OperationalState, rhs: OperationalState) -> Bool {
24
            return lhs.rawValue < rhs.rawValue
25
        }
26
    }
27

            
28
    private var administrativeState = AdministrativeState.down
29
    private var operationalState = OperationalState.peripheralNotConnected {
30
        didSet {
31
            delegate?.opertionalStateChanged(to: operationalState)
32
        }
33
    }
34

            
35
    var macAddress: MACAddress
36
    private var manager: CBCentralManager
37
    private var radio: BluetoothRadio
Bogdan Timofte authored 2 months ago
38
    private(set) var rawRSSI: Int
39
    private var rssiSamples: [Int] = []
40
    private let rssiAveragingWindow = 3
41
    @Published private(set) var averageRSSI: Int
42
    private(set) var minRSSI: Int
43
    private(set) var maxRSSI: Int
Bogdan Timofte authored 2 months ago
44

            
45
    private var expectedResponseLength = 0
46
    private var wdTimer: Timer?
47

            
48
    var peripheral: CBPeripheral
49

            
Bogdan Timofte authored 2 months ago
50
    /// The characteristic used for writes on the connected peripheral.
Bogdan Timofte authored 2 months ago
51
    private var writeCharacteristic: CBCharacteristic?
Bogdan Timofte authored 2 months ago
52
    /// The characteristic used for notifications on the connected peripheral.
53
    private var notifyCharacteristic: CBCharacteristic?
Bogdan Timofte authored 2 months ago
54

            
55
    private var buffer = Data()
56

            
57
    weak var delegate: SerialPortDelegate?
58

            
59
    init( peripheral: CBPeripheral, radio: BluetoothRadio, with macAddress: MACAddress, managedBy manager: CBCentralManager, RSSI: Int ) {
60

            
61
        self.peripheral = peripheral
62
        self.macAddress = macAddress
63
        self.radio = radio
64
        self.manager = manager
Bogdan Timofte authored 2 months ago
65
        self.rawRSSI = RSSI
66
        self.rssiSamples = [RSSI]
67
        self.averageRSSI = RSSI
Bogdan Timofte authored 2 months ago
68
        self.minRSSI = RSSI
69
        self.maxRSSI = RSSI
Bogdan Timofte authored 2 months ago
70
        Timer.scheduledTimer(withTimeInterval: 3, repeats: true, block: {_ in
71
            if peripheral.state == .connected {
72
                peripheral.readRSSI()
73
            }
74
        })
75
        super.init()
76
        peripheral.delegate = self
77
    }
Bogdan Timofte authored 2 months ago
78

            
Bogdan Timofte authored 2 months ago
79
    func updateRSSI(_ value: Int) {
80
        rawRSSI = value
81
        rssiSamples.append(value)
82
        if rssiSamples.count > rssiAveragingWindow {
83
            rssiSamples.removeFirst()
84
        }
85
        let newAverage = rssiSamples.reduce(0, +) / rssiSamples.count
86
        minRSSI = Swift.min(minRSSI, newAverage)
87
        maxRSSI = Swift.max(maxRSSI, newAverage)
88
        if newAverage != averageRSSI {
89
            averageRSSI = newAverage
90
        }
91
    }
92

            
Bogdan Timofte authored 2 months ago
93
    private func resetCommunicationState(reason: String, clearCharacteristics: Bool) {
94
        if wdTimer != nil {
95
            track("Reset communication state (\(reason)) - invalidating watchdog")
96
        }
97
        wdTimer?.invalidate()
98
        wdTimer = nil
99

            
100
        if expectedResponseLength != 0 || !buffer.isEmpty {
101
            track("Reset communication state (\(reason)) - expected: \(expectedResponseLength), buffered: \(buffer.count)")
102
        }
103
        expectedResponseLength = 0
104
        buffer.removeAll()
105

            
106
        if clearCharacteristics {
107
            writeCharacteristic = nil
108
            notifyCharacteristic = nil
109
        }
110
    }
Bogdan Timofte authored 2 months ago
111

            
112
    private func forceNotConnected(reason: String, clearCharacteristics: Bool = true) {
113
        resetCommunicationState(reason: reason, clearCharacteristics: clearCharacteristics)
114
        guard operationalState != .peripheralNotConnected else {
115
            return
116
        }
117
        operationalState = .peripheralNotConnected
118
    }
Bogdan Timofte authored 2 months ago
119

            
120
    func connect() {
121
        administrativeState = .up
Bogdan Timofte authored 2 months ago
122
        guard manager.state == .poweredOn else {
123
            track("Connect requested for '\(peripheral.identifier)' but central state is \(manager.state)")
124
            forceNotConnected(reason: "connect() while central is \(manager.state)")
125
            return
126
        }
Bogdan Timofte authored 2 months ago
127
        if operationalState < .peripheralConnected {
Bogdan Timofte authored 2 months ago
128
            resetCommunicationState(reason: "connect()", clearCharacteristics: true)
Bogdan Timofte authored 2 months ago
129
            operationalState = .peripheralConnectionPending
Bogdan Timofte authored 2 months ago
130
            track("Connect called for '\(peripheral.identifier)' while peripheral state is \(peripheral.state.rawValue)")
Bogdan Timofte authored 2 months ago
131
            manager.connect(peripheral, options: nil)
132
        } else {
133
            track("Peripheral allready connected: \(operationalState)")
134
        }
135
    }
136

            
137
    func disconnect() {
Bogdan Timofte authored 2 months ago
138
        administrativeState = .down
139
        resetCommunicationState(reason: "disconnect()", clearCharacteristics: true)
140
        if peripheral.state != .disconnected || operationalState != .peripheralNotConnected {
141
            track("Disconnect requested for '\(peripheral.identifier)' while peripheral state is \(peripheral.state.rawValue)")
Bogdan Timofte authored 2 months ago
142
            guard manager.state == .poweredOn else {
143
                track("Skipping central cancel for '\(peripheral.identifier)' because central state is \(manager.state)")
144
                forceNotConnected(reason: "disconnect() while central is \(manager.state)", clearCharacteristics: false)
145
                return
146
            }
Bogdan Timofte authored 2 months ago
147
            manager.cancelPeripheralConnection(peripheral)
148
        }
149
    }
150

            
151
    /**
152
     Send data
153

            
154
     - parameter data: Data to be sent.
155
     - 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
156
     */
157
    func write(_ data: Data, expectedResponseLength: Int = 0) {
158
        guard operationalState == .peripheralReady else {
Bogdan Timofte authored a month ago
159
            track("Write skipped while peripheral state is \(operationalState)")
Bogdan Timofte authored 2 months ago
160
            return
161
        }
162
        guard self.expectedResponseLength == 0 else {
Bogdan Timofte authored a month ago
163
            track("Write skipped while waiting for \(self.expectedResponseLength) response bytes")
Bogdan Timofte authored 2 months ago
164
            return
165
        }
166

            
167
        self.expectedResponseLength = expectedResponseLength
168

            
Bogdan Timofte authored 2 months ago
169
        guard let writeCharacteristic else {
170
            track("Missing write characteristic for \(radio)")
171
            self.expectedResponseLength = 0
172
            return
Bogdan Timofte authored 2 months ago
173
        }
Bogdan Timofte authored 2 months ago
174

            
175
        let writeType: CBCharacteristicWriteType = writeCharacteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse
176
        peripheral.writeValue(data, for: writeCharacteristic, type: writeType)
Bogdan Timofte authored 2 months ago
177
        if self.expectedResponseLength != 0 {
178
            setWDT()
179
        }
180
    }
181

            
182
    func connectionEstablished () {
Bogdan Timofte authored 2 months ago
183
        resetCommunicationState(reason: "connectionEstablished()", clearCharacteristics: true)
184
        track("Connection established for '\(peripheral.identifier)'")
Bogdan Timofte authored 2 months ago
185
        rssiSamples = [rawRSSI]
186
        averageRSSI = rawRSSI
187
        minRSSI = rawRSSI
188
        maxRSSI = rawRSSI
Bogdan Timofte authored 2 months ago
189
        operationalState = .peripheralConnected
190
        peripheral.discoverServices(BluetoothRadioServicesUUIDS[radio])
191
    }
192

            
193
    func connectionClosed () {
Bogdan Timofte authored 2 months ago
194
        track("Connection closed for '\(peripheral.identifier)' while peripheral state is \(peripheral.state.rawValue)")
195
        resetCommunicationState(reason: "connectionClosed()", clearCharacteristics: true)
Bogdan Timofte authored 2 months ago
196
        rssiSamples = [rawRSSI]
197
        averageRSSI = rawRSSI
198
        minRSSI = rawRSSI
199
        maxRSSI = rawRSSI
Bogdan Timofte authored 2 months ago
200
        operationalState = .peripheralNotConnected
201
    }
202

            
Bogdan Timofte authored 2 months ago
203
    func centralStateChanged(to newState: CBManagerState) {
204
        switch newState {
205
        case .poweredOn:
206
            if administrativeState == .up,
207
               operationalState == .peripheralNotConnected,
208
               peripheral.state == .disconnected {
209
                track("Central returned to poweredOn. Restoring connection to '\(peripheral.identifier)'")
210
                connect()
211
            }
212
        case .poweredOff, .resetting, .unauthorized, .unknown, .unsupported:
213
            if operationalState != .peripheralNotConnected || expectedResponseLength != 0 || !buffer.isEmpty {
214
                track("Central changed to \(newState). Forcing '\(peripheral.identifier)' to not connected.")
215
            }
216
            forceNotConnected(reason: "centralStateChanged(\(newState))")
217
        @unknown default:
218
            track("Central changed to an unknown state. Forcing '\(peripheral.identifier)' to not connected.")
219
            forceNotConnected(reason: "centralStateChanged(@unknown default)")
220
        }
221
    }
222

            
Bogdan Timofte authored 2 months ago
223
    func setWDT() {
224
        wdTimer?.invalidate()
225
        wdTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false, block: {_ in
226
            track("Response timeout. Expected: \(self.expectedResponseLength) - buffer: \(self.buffer.count)")
227
            self.expectedResponseLength = 0
228
            self.disconnect()
229
        })
230
    }
231

            
Bogdan Timofte authored 2 months ago
232
    private func refreshOperationalStateIfReady() {
Bogdan Timofte authored 2 months ago
233
        guard let notifyCharacteristic, let writeCharacteristic else {
Bogdan Timofte authored 2 months ago
234
            return
235
        }
Bogdan Timofte authored 2 months ago
236
        guard notifyCharacteristic.isNotifying else {
237
            track("Waiting for notifications on '\(notifyCharacteristic.uuid)' before marking peripheral ready")
238
            return
239
        }
240
        track("Peripheral ready with notify '\(notifyCharacteristic.uuid)' and write '\(writeCharacteristic.uuid)'")
Bogdan Timofte authored 2 months ago
241
        operationalState = .peripheralReady
242
    }
243

            
244
    private func updateBT18Characteristics(for service: CBService) {
245
        for characteristic in service.characteristics ?? [] {
246
            switch characteristic.uuid {
247
            case CBUUID(string: "FFE1"):
248
                if characteristic.properties.contains(.notify) || characteristic.properties.contains(.indicate) {
249
                    peripheral.setNotifyValue(true, for: characteristic)
250
                    notifyCharacteristic = characteristic
251
                }
252
                if writeCharacteristic == nil &&
253
                    (characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse)) {
254
                    writeCharacteristic = characteristic
255
                }
256
            case CBUUID(string: "FFE2"):
257
                if characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse) {
258
                    // DX-BT18 documents FFE2 as the preferred write-only endpoint when present.
259
                    writeCharacteristic = characteristic
260
                }
261
            default:
262
                track ("Unexpected characteristic discovered: '\(characteristic)'")
263
            }
264
        }
265
        refreshOperationalStateIfReady()
266
    }
267

            
268
    private func updatePW0316Characteristics(for service: CBService) {
269
        for characteristic in service.characteristics ?? [] {
270
            switch characteristic.uuid {
271
            case CBUUID(string: "FFE9"): // TX from BLE side into UART
272
                writeCharacteristic = characteristic
273
            case CBUUID(string: "FFE4"): // RX notifications from UART side into BLE
274
                peripheral.setNotifyValue(true, for: characteristic)
275
                notifyCharacteristic = characteristic
276
            default:
277
                track ("Unexpected characteristic discovered: '\(characteristic)'")
278
            }
279
        }
280
        refreshOperationalStateIfReady()
281
    }
282

            
Bogdan Timofte authored 2 months ago
283
}
284

            
285
//  MARK:   CBPeripheralDelegate
286
extension BluetoothSerial : CBPeripheralDelegate {
287

            
288
    //  MARK:   didReadRSSI
289
    func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) {
290
        if error != nil {
291
            track( "Error: \(error!)" )
292
        }
Bogdan Timofte authored 2 months ago
293
        updateRSSI(RSSI.intValue)
Bogdan Timofte authored 2 months ago
294
    }
295

            
296
    //  MARK:   didDiscoverServices
297
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
298
        if error != nil {
299
            track( "Error: \(error!)" )
300
        }
301
        switch radio {
Bogdan Timofte authored a month ago
302
        case .BT18, .BT24M:
Bogdan Timofte authored 2 months ago
303
            for service in peripheral.services! {
304
                switch service.uuid {
305
                case CBUUID(string: "FFE0"):
Bogdan Timofte authored 2 months ago
306
                    peripheral.discoverCharacteristics(Array(Set((BluetoothRadioNotifyUUIDs[radio] ?? []) + (BluetoothRadioWriteUUIDs[radio] ?? []))), for: service)
Bogdan Timofte authored 2 months ago
307
                default:
308
                    track ("Unexpected service discovered: '\(service)'")
309
                }
310
            }
311
        case .PW0316:
312
            for service in peripheral.services! {
313
                switch service.uuid {
314
                case CBUUID(string: "FFE0"):
Bogdan Timofte authored 2 months ago
315
                    peripheral.discoverCharacteristics(BluetoothRadioNotifyUUIDs[radio], for: service)
Bogdan Timofte authored 2 months ago
316
                case CBUUID(string: "FFE5"):
Bogdan Timofte authored 2 months ago
317
                    peripheral.discoverCharacteristics(BluetoothRadioWriteUUIDs[radio], for: service)
Bogdan Timofte authored 2 months ago
318
                default:
319
                    track ("Unexpected service discovered: '\(service)'")
320
                }
321
            }
322
        default:
323
            track("Radio \(radio) Not Implemented!")
324
        }
325
    }
326

            
327
    //  MARK:   didDiscoverCharacteristicsFor
328
    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
329
        if error != nil {
330
            track( "Error: \(error!)" )
331
        }
332
        switch radio {
Bogdan Timofte authored a month ago
333
        case .BT18, .BT24M:
Bogdan Timofte authored 2 months ago
334
            updateBT18Characteristics(for: service)
Bogdan Timofte authored 2 months ago
335
        case .PW0316:
Bogdan Timofte authored 2 months ago
336
            updatePW0316Characteristics(for: service)
Bogdan Timofte authored 2 months ago
337
        default:
338
            track("Radio \(radio) Not Implemented!")
339
        }
340
    }
Bogdan Timofte authored 2 months ago
341

            
342
    func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
343
        if error != nil {
344
            track("Error updating notification state for '\(characteristic.uuid)': \(error!)")
345
        }
346
        track("Notification state updated for '\(characteristic.uuid)' - isNotifying: \(characteristic.isNotifying)")
347
        if characteristic.uuid == notifyCharacteristic?.uuid {
348
            refreshOperationalStateIfReady()
349
        }
350
    }
Bogdan Timofte authored 2 months ago
351

            
352
    //  MARK:   didUpdateValueFor
353
    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
354
        if error != nil {
355
            track( "Error: \(error!)" )
356
        }
Bogdan Timofte authored 2 months ago
357
        let incomingData = characteristic.value ?? Data()
358
        guard !incomingData.isEmpty else {
359
            track("Received empty update for '\(characteristic.uuid)'")
360
            return
361
        }
362
        guard expectedResponseLength > 0 else {
363
            if !buffer.isEmpty {
364
                track("Dropping unsolicited data (\(incomingData.count) bytes) for '\(characteristic.uuid)' with residual buffer: \(buffer.count)")
365
                buffer.removeAll()
366
            } else {
367
                track("Dropping unsolicited data (\(incomingData.count) bytes) for '\(characteristic.uuid)' while no response is expected")
368
            }
369
            return
370
        }
371

            
372
        let previousBufferCount = buffer.count
373
        buffer.append(incomingData)
Bogdan Timofte authored 2 months ago
374
        switch buffer.count {
375
        case let x where x < expectedResponseLength:
376
            setWDT()
377
            break;
378
        case let x where x == expectedResponseLength:
379
            wdTimer?.invalidate()
380
            expectedResponseLength = 0
381
            delegate?.didReceiveData(buffer)
382
            buffer.removeAll()
383
        case let x where x > expectedResponseLength:
384
            // MARK: De unde stim că asta a fost tot? Probabil o deconectare ar rezolva problema
Bogdan Timofte authored 2 months ago
385
            let expectedLength = expectedResponseLength
386
            track("Buffer overflow for '\(characteristic.uuid)'. Chunk: \(incomingData.count), previous buffer: \(previousBufferCount), total: \(x), expected: \(expectedLength). Disconnecting to recover.")
387
            disconnect()
Bogdan Timofte authored 2 months ago
388
        default:
389
            track("This is not possible!")
390
        }
391
    }
392

            
393
    func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
394
        if error != nil { track( "Error: \(error!)" ) }
395
    }
396

            
397
    func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
398
    }
399

            
400
}
401

            
402
// MARK: SerialPortDelegate
403
protocol SerialPortDelegate: AnyObject {
404
    // MARK: State Changed
405
    func opertionalStateChanged( to newOperationalState: BluetoothSerial.OperationalState )
406
    // MARK: Data was received
407
    func didReceiveData(_ data: Data)
408
}
409

            
410
// MARK: SerialPortDelegate Optionals
411
extension SerialPortDelegate {
412
}