USB-Meter / USB Meter / Model / BluetoothManager.swift
Newer Older
329 lines | 14.906kb
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
            track("adding new USB Meter named '\(advertisedName)' with MAC Address: '\(macAddress)'")
96
            let btSerial = BluetoothSerial(peripheral: peripheral, radio: radio, with: macAddress, managedBy: manager!, RSSI: RSSI.intValue)
Bogdan Timofte authored 2 months ago
97
            var m = appData.meters
Bogdan Timofte authored a month ago
98
            let meter = Meter(model: model, with: btSerial)
99
            m[peripheral.identifier] = meter
Bogdan Timofte authored 2 months ago
100
            appData.meters = m
Bogdan Timofte authored a month ago
101
            appData.restoreChargeMonitoringStateIfNeeded(for: meter)
Bogdan Timofte authored 2 months ago
102
        } else if let meter = appData.meters[peripheral.identifier] {
103
            meter.lastSeen = Date()
Bogdan Timofte authored 2 months ago
104
            meter.btSerial.updateRSSI(RSSI.intValue)
Bogdan Timofte authored 2 months ago
105
            let macAddress = meter.btSerial.macAddress.description
106
            if meter.name == macAddress, let syncedName = appData.meterName(for: macAddress), syncedName != macAddress {
107
                meter.updateNameFromStore(syncedName)
108
            }
Bogdan Timofte authored 2 months ago
109
            if peripheral.delegate == nil {
110
                peripheral.delegate = meter.btSerial
111
            }
112
        }
113
    }
Bogdan Timofte authored a month ago
114

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

            
122
        return nil
123
    }
124

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

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

            
134
        return model.radio
135
    }
136

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

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

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

            
159
    private func logDiscovery(_ message: String) {
160
        track(message)
161
        bluetoothDiscoveryLogger.notice("\(message, privacy: .public)")
162
    }
163

            
164
    private func shouldLogDiscoveryDetails(for identifier: UUID) -> Bool {
165
        guard ProcessInfo.processInfo.environment["USB_METER_VERBOSE_LOGS"] == "1" else {
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;
231
        track("\(central.state)")
Bogdan Timofte authored 2 months ago
232
        for meter in appData.meters.values {
233
            meter.btSerial.centralStateChanged(to: central.state)
234
        }
Bogdan Timofte authored 2 months ago
235

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

            
271
    // MARK:  CBCentralManager didDiscover peripheral
272
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
273
        let completeAdvertisementData = self.advertisementDataCache.append(peripheral: peripheral, advertisementData: advertisementData)
274
        //track("Device discoverded UUID: '\(peripheral.identifier)' named '\(peripheral.name ?? "Unknown")'); RSSI: \(RSSI) dBm; Advertisment data: \(advertisementData)")
275
        discoveredMeter(peripheral: peripheral, advertising: completeAdvertisementData, rssi: RSSI )
276
    }
277

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

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

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

            
310
private class AdvertisementDataCache {
311

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

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

            
326
    func clear() {
327
        map.removeAll()
328
    }
329
}