USB-Meter / USB Meter / Model / BluetoothManager.swift
Newer Older
324 lines | 14.445kb
Bogdan Timofte authored 2 months ago
1
//
2
//  BTManager.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 01/03/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8

            
9
import CoreBluetooth
Bogdan Timofte authored a month ago
10
import OSLog
11

            
12
private let bluetoothDiscoveryLogger = Logger(subsystem: "ro.xdev.USB-Meter", category: "BluetoothDiscovery")
Bogdan Timofte authored 2 months ago
13

            
14
class BluetoothManager : NSObject, ObservableObject {
Bogdan Timofte authored 2 months ago
15
    private var manager: CBCentralManager?
Bogdan Timofte authored 2 months ago
16
    private var isStarting = false
Bogdan Timofte authored 2 months ago
17
    private var advertisementDataCache = AdvertisementDataCache()
Bogdan Timofte authored a month ago
18
    private var lastDiscoveryLog = [String: Date]()
Bogdan Timofte authored 2 months ago
19
    @Published var managerState = CBManagerState.unknown
Bogdan Timofte authored 2 months ago
20
    @Published private(set) var scanStartedAt: Date?
Bogdan Timofte authored 2 months ago
21

            
22
    override init () {
23
        super.init()
24
    }
25

            
Bogdan Timofte authored 2 months ago
26
    func start() {
Bogdan Timofte authored 2 months ago
27
        guard manager == nil, !isStarting else {
Bogdan Timofte authored 2 months ago
28
            return
29
        }
Bogdan Timofte authored 2 months ago
30
        isStarting = true
Bogdan Timofte authored 2 months ago
31
        track("Starting Bluetooth manager and requesting authorization if needed")
Bogdan Timofte authored 2 months ago
32
        DispatchQueue.main.async { [weak self] in
33
            guard let self else { return }
34
            defer { self.isStarting = false }
35
            guard self.manager == nil else {
36
                return
37
            }
38
            self.manager = CBCentralManager(delegate: self, queue: nil)
39
        }
Bogdan Timofte authored 2 months ago
40
    }
Bogdan Timofte authored 2 months ago
41

            
42
    private func scanForMeters() {
Bogdan Timofte authored 2 months ago
43
        guard let manager else {
44
            track("Scan requested before Bluetooth manager was started")
45
            return
46
        }
Bogdan Timofte authored 2 months ago
47
        guard manager.state == .poweredOn else {
48
            track( "Scan requested but Bluetooth state is \(manager.state)")
49
            return
50
        }
51
        //manager.scanForPeripherals(withServices: allBluetoothRadioServices(), options: [ CBCentralManagerScanOptionAllowDuplicatesKey: true ])
Bogdan Timofte authored a month ago
52
        let serviceUUIDs = allBluetoothRadioServices()
53
        track("Scanning for USB meters with services: \(serviceUUIDs.map(\.uuidString).joined(separator: ", "))")
54
        manager.scanForPeripherals(withServices: serviceUUIDs, options: [ CBCentralManagerScanOptionAllowDuplicatesKey: true ])
Bogdan Timofte authored 2 months ago
55
    }
56

            
57
    func discoveredMeter(peripheral: CBPeripheral, advertising advertismentData: [String : Any], rssi RSSI: NSNumber) {
Bogdan Timofte authored a month ago
58
        logDiscoveryCandidate(peripheral: peripheral, advertising: advertismentData, rssi: RSSI)
59
        let peripheralName = resolvedPeripheralName(for: peripheral, advertising: advertismentData)
60
        guard let match = resolvedModel(for: peripheralName, advertising: advertismentData) else {
61
            let reason: String
62
            if let peripheralName {
63
                reason = "unrecognized peripheral name '\(peripheralName)'; known names: \(Model.knownPeripheralNames.joined(separator: ", "))"
64
            } else {
65
                reason = "missing peripheral name/local name"
66
            }
67
            logDiscoveryRejection(
68
                peripheral: peripheral,
69
                reason: reason,
70
                advertising: advertismentData,
71
                rssi: RSSI
72
            )
Bogdan Timofte authored 2 months ago
73
            return
74
        }
Bogdan Timofte authored a month ago
75
        let model = match.model
76
        let radio = match.radio
77
        let advertisedName = match.advertisedName
78

            
79
        guard let macAddress = resolvedMACAddress(from: advertismentData) else {
80
            logDiscoveryRejection(
81
                peripheral: peripheral,
82
                reason: "missing or short manufacturer data for '\(advertisedName)'",
83
                advertising: advertismentData,
84
                rssi: RSSI
85
            )
Bogdan Timofte authored 2 months ago
86
            return
87
        }
88

            
Bogdan Timofte authored 2 months ago
89
        let macAddressString = macAddress.description
Bogdan Timofte authored a month ago
90
        appData.registerMeter(macAddress: macAddressString, modelName: model.canonicalName, advertisedName: advertisedName)
Bogdan Timofte authored 2 months ago
91
        appData.noteMeterSeen(at: Date(), macAddress: macAddressString)
Bogdan Timofte authored 2 months ago
92

            
93
        if appData.meters[peripheral.identifier] == nil {
Bogdan Timofte authored a month ago
94
            logDiscovery("BLE discovery accepted: model='\(model.canonicalName)', radio='\(radio)', advertisedName='\(advertisedName)', match='\(match.reason)', macAddress='\(macAddressString)'. \(discoveryDescription(peripheral: peripheral, advertising: advertismentData, rssi: RSSI))")
95
            let btSerial = BluetoothSerial(peripheral: peripheral, radio: radio, with: macAddress, managedBy: manager!, RSSI: RSSI.intValue)
Bogdan Timofte authored 2 months ago
96
            var m = appData.meters
Bogdan Timofte authored a month ago
97
            let meter = Meter(model: model, with: btSerial)
98
            m[peripheral.identifier] = meter
Bogdan Timofte authored 2 months ago
99
            appData.meters = m
Bogdan Timofte authored a month ago
100
            appData.restoreChargeMonitoringStateIfNeeded(for: meter)
Bogdan Timofte authored 2 months ago
101
        } else if let meter = appData.meters[peripheral.identifier] {
102
            meter.lastSeen = Date()
Bogdan Timofte authored 2 months ago
103
            meter.btSerial.updateRSSI(RSSI.intValue)
Bogdan Timofte authored 2 months ago
104
            let macAddress = meter.btSerial.macAddress.description
105
            if meter.name == macAddress, let syncedName = appData.meterName(for: macAddress), syncedName != macAddress {
106
                meter.updateNameFromStore(syncedName)
107
            }
Bogdan Timofte authored 2 months ago
108
            if peripheral.delegate == nil {
109
                peripheral.delegate = meter.btSerial
110
            }
111
        }
112
    }
Bogdan Timofte authored a month ago
113

            
114
    private func resolvedModel(for peripheralName: String?, advertising advertismentData: [String: Any]) -> (model: Model, advertisedName: String, radio: BluetoothRadio, reason: String)? {
115
        if let peripheralName {
116
            if let model = Model.model(forPeripheralName: peripheralName) {
117
                return (model, peripheralName, radio(for: model, peripheralName: peripheralName), "recognized peripheral name")
118
            }
119
        }
120

            
121
        return nil
122
    }
123

            
124
    private func radio(for model: Model, peripheralName: String) -> BluetoothRadio {
125
        guard model == .TC66C else {
126
            return model.radio
127
        }
128

            
129
        if peripheralName.caseInsensitiveCompare("BT24-M") == .orderedSame {
130
            return .BT24M
131
        }
132

            
133
        return model.radio
134
    }
135

            
136
    private func resolvedMACAddress(from advertismentData: [String: Any]) -> MACAddress? {
137
        guard let manufacturerData = resolvedManufacturerData(from: advertismentData), manufacturerData.count > 2 else {
138
            return nil
139
        }
140
        return MACAddress(from: manufacturerData.suffix(from: 2))
141
    }
142

            
143
    private func logDiscoveryCandidate(peripheral: CBPeripheral, advertising advertismentData: [String: Any], rssi RSSI: NSNumber) {
144
        guard shouldLogDiscoveryDetails(for: peripheral.identifier) else { return }
145
        logDiscovery("BLE discovery candidate: \(discoveryDescription(peripheral: peripheral, advertising: advertismentData, rssi: RSSI))")
146
    }
147

            
148
    private func logDiscoveryRejection(
149
        peripheral: CBPeripheral,
150
        reason: String,
151
        advertising advertismentData: [String: Any],
152
        rssi RSSI: NSNumber
153
    ) {
154
        guard shouldLogDiscoveryRejection(for: peripheral.identifier, reason: reason) else { return }
155
        logDiscovery("BLE discovery rejected: \(reason). \(discoveryDescription(peripheral: peripheral, advertising: advertismentData, rssi: RSSI))")
156
    }
157

            
158
    private func logDiscovery(_ message: String) {
159
        track(message)
Bogdan Timofte authored a month ago
160
        guard debugLogFlagEnabled("USB_METER_BLUETOOTH_LOGS") else { return }
161
        bluetoothDiscoveryLogger.debug("\(message, privacy: .public)")
Bogdan Timofte authored a month ago
162
    }
Bogdan Timofte authored a month ago
163

            
Bogdan Timofte authored a month ago
164
    private func shouldLogDiscoveryDetails(for identifier: UUID) -> Bool {
Bogdan Timofte authored a month ago
165
        guard debugLogFlagEnabled("USB_METER_BLUETOOTH_LOGS") else {
Bogdan Timofte authored a month ago
166
            return false
167
        }
168
        return shouldLogDiscoveryDetails(for: identifier.uuidString)
169
    }
170

            
171
    private func shouldLogDiscoveryRejection(for identifier: UUID, reason: String) -> Bool {
172
        shouldLogDiscoveryDetails(for: "\(identifier.uuidString):\(reason)")
173
    }
174

            
175
    private func shouldLogDiscoveryDetails(for key: String) -> Bool {
176
        let now = Date()
177
        if let lastLoggedAt = lastDiscoveryLog[key], now.timeIntervalSince(lastLoggedAt) < 5 {
178
            return false
179
        }
180
        lastDiscoveryLog[key] = now
181
        return true
182
    }
183

            
184
    private func discoveryDescription(peripheral: CBPeripheral, advertising advertismentData: [String: Any], rssi RSSI: NSNumber) -> String {
185
        let localName = advertismentData[CBAdvertisementDataLocalNameKey] as? String
186
        let services = serviceUUIDs(from: advertismentData, key: CBAdvertisementDataServiceUUIDsKey).map(\.uuidString).joined(separator: ", ")
187
        let overflowServices = serviceUUIDs(from: advertismentData, key: CBAdvertisementDataOverflowServiceUUIDsKey).map(\.uuidString).joined(separator: ", ")
188
        let solicitedServices = serviceUUIDs(from: advertismentData, key: CBAdvertisementDataSolicitedServiceUUIDsKey).map(\.uuidString).joined(separator: ", ")
189
        let manufacturerData = resolvedManufacturerData(from: advertismentData)
190
        let manufacturerSummary = manufacturerData.map { "\($0.count)b \($0.hexEncodedStringValue)" } ?? "nil"
191
        let txPower = advertismentData[CBAdvertisementDataTxPowerLevelKey].map { "\($0)" } ?? "nil"
192
        let connectable = advertismentData[CBAdvertisementDataIsConnectable].map { "\($0)" } ?? "nil"
193

            
194
        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)"
195
    }
Bogdan Timofte authored 2 months ago
196

            
197
    private func resolvedPeripheralName(for peripheral: CBPeripheral, advertising advertismentData: [String : Any]) -> String? {
198
        let candidates = [
199
            (advertismentData[CBAdvertisementDataLocalNameKey] as? String),
200
            peripheral.name
201
        ]
202

            
203
        for candidate in candidates {
204
            if let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty {
205
                return trimmed
Bogdan Timofte authored 2 months ago
206
            }
207
        }
Bogdan Timofte authored 2 months ago
208

            
209
        return nil
210
    }
Bogdan Timofte authored a month ago
211

            
212
    private func serviceUUIDs(from advertismentData: [String : Any], key: String) -> [CBUUID] {
213
        advertismentData[key] as? [CBUUID] ?? []
214
    }
Bogdan Timofte authored 2 months ago
215

            
216
    private func resolvedManufacturerData(from advertismentData: [String : Any]) -> Data? {
217
        if let data = advertismentData[CBAdvertisementDataManufacturerDataKey] as? Data {
218
            return data
Bogdan Timofte authored 2 months ago
219
        }
Bogdan Timofte authored 2 months ago
220
        if let data = advertismentData["kCBAdvDataManufacturerData"] as? Data {
221
            return data
222
        }
223
        return nil
Bogdan Timofte authored 2 months ago
224
    }
225
}
226

            
227
extension BluetoothManager : CBCentralManagerDelegate {
228
    // MARK:  CBCentralManager state Changed
229
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
230
        managerState = central.state;
Bogdan Timofte authored a month ago
231
        for meter in appData.meters.values {
232
            meter.btSerial.centralStateChanged(to: central.state)
233
        }
Bogdan Timofte authored 2 months ago
234

            
235
        switch central.state {
236
        case .poweredOff:
Bogdan Timofte authored 2 months ago
237
            scanStartedAt = nil
Bogdan Timofte authored a month ago
238
            advertisementDataCache.clear()
Bogdan Timofte authored 2 months ago
239
        case .poweredOn:
Bogdan Timofte authored 2 months ago
240
            scanStartedAt = Date()
Bogdan Timofte authored 2 months ago
241
            // note that "didDisconnectPeripheral" won't be called if BLE is turned off while connected
242
            // connectedPeripheral = nil
243
            // pendingPeripheral = nil
Bogdan Timofte authored 2 months ago
244
            scanForMeters()
Bogdan Timofte authored 2 months ago
245
        case .resetting:
Bogdan Timofte authored 2 months ago
246
            scanStartedAt = nil
Bogdan Timofte authored a month ago
247
            advertisementDataCache.clear()
Bogdan Timofte authored a month ago
248
            track("Bluetooth is resetting.")
Bogdan Timofte authored 2 months ago
249
        case .unauthorized:
Bogdan Timofte authored 2 months ago
250
            scanStartedAt = nil
Bogdan Timofte authored a month ago
251
            advertisementDataCache.clear()
Bogdan Timofte authored 2 months ago
252
            track("Bluetooth is not authorized.")
253
        case .unknown:
Bogdan Timofte authored 2 months ago
254
            scanStartedAt = nil
Bogdan Timofte authored a month ago
255
            advertisementDataCache.clear()
Bogdan Timofte authored 2 months ago
256
            track("Bluetooth is in an unknown state.")
257
        case .unsupported:
Bogdan Timofte authored 2 months ago
258
            scanStartedAt = nil
Bogdan Timofte authored a month ago
259
            advertisementDataCache.clear()
Bogdan Timofte authored 2 months ago
260
            track("Bluetooth not supported by device")
261
        default:
Bogdan Timofte authored 2 months ago
262
            scanStartedAt = nil
Bogdan Timofte authored a month ago
263
            advertisementDataCache.clear()
Bogdan Timofte authored 2 months ago
264
            track("Bluetooth is in a state never seen before!")
265
        }
266
    }
267

            
268
    // MARK:  CBCentralManager didDiscover peripheral
269
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
270
        let completeAdvertisementData = self.advertisementDataCache.append(peripheral: peripheral, advertisementData: advertisementData)
271
        discoveredMeter(peripheral: peripheral, advertising: completeAdvertisementData, rssi: RSSI )
272
    }
273

            
274
    // MARK:  CBCentralManager didConnect peripheral
275
    internal func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
276
        if let usbMeter = appData.meters[peripheral.identifier] {
277
            usbMeter.btSerial.connectionEstablished()
278
        }
279
        else {
Bogdan Timofte authored 2 months ago
280
            track("Connected to meter with UUID: '\(peripheral.identifier)'")
Bogdan Timofte authored 2 months ago
281
        }
282
    }
283

            
284
    // MARK:  CBCentralManager didDisconnectPeripheral peripheral
285
    internal func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
286
        track("Disconnected from peripheral: '\(peripheral.identifier)' with error: \(error.debugDescription)")
287
        if let usbMeter = appData.meters[peripheral.identifier] {
288
            usbMeter.btSerial.connectionClosed()
289
        }
290
        else {
Bogdan Timofte authored 2 months ago
291
            track("Disconnected from meter with UUID: '\(peripheral.identifier)'")
Bogdan Timofte authored 2 months ago
292
        }
293
    }
Bogdan Timofte authored 2 months ago
294

            
295
    internal func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
296
        track("Failed to connect to peripheral: '\(peripheral.identifier)' with error: \(error.debugDescription)")
297
        if let usbMeter = appData.meters[peripheral.identifier] {
298
            usbMeter.btSerial.connectionClosed()
299
        } else {
Bogdan Timofte authored 2 months ago
300
            track("Failed to connect to meter with UUID: '\(peripheral.identifier)'")
Bogdan Timofte authored 2 months ago
301
        }
302
    }
Bogdan Timofte authored 2 months ago
303
}
304

            
305
private class AdvertisementDataCache {
306

            
Bogdan Timofte authored 2 months ago
307
    private var map = [UUID: [String: Any]]()
Bogdan Timofte authored 2 months ago
308

            
309
    func append(peripheral: CBPeripheral, advertisementData: [String: Any]) -> [String: Any] {
310
        var ad = (map[peripheral.identifier]) ?? [String: Any]()
Bogdan Timofte authored 2 months ago
311
        if let localName = peripheral.name?.trimmingCharacters(in: .whitespacesAndNewlines), !localName.isEmpty {
312
            ad[CBAdvertisementDataLocalNameKey] = localName
313
        }
Bogdan Timofte authored 2 months ago
314
        for (key, value) in advertisementData {
315
            ad[key] = value
316
        }
317
        map[peripheral.identifier] = ad
318
        return ad
319
    }
320

            
321
    func clear() {
322
        map.removeAll()
323
    }
324
}