Showing 4 changed files with 143 additions and 17 deletions
+123 -11
USB Meter/Model/BluetoothManager.swift
@@ -7,11 +7,15 @@
7 7
 //
8 8
 
9 9
 import CoreBluetooth
10
+import OSLog
11
+
12
+private let bluetoothDiscoveryLogger = Logger(subsystem: "ro.xdev.USB-Meter", category: "BluetoothDiscovery")
10 13
 
11 14
 class BluetoothManager : NSObject, ObservableObject {
12 15
     private var manager: CBCentralManager?
13 16
     private var isStarting = false
14 17
     private var advertisementDataCache = AdvertisementDataCache()
18
+    private var lastDiscoveryLog = [String: Date]()
15 19
     @Published var managerState = CBManagerState.unknown
16 20
     @Published private(set) var scanStartedAt: Date?
17 21
     
@@ -45,29 +49,51 @@ class BluetoothManager : NSObject, ObservableObject {
45 49
             return
46 50
         }
47 51
         //manager.scanForPeripherals(withServices: allBluetoothRadioServices(), options: [ CBCentralManagerScanOptionAllowDuplicatesKey: true ])
48
-        manager.scanForPeripherals(withServices: allBluetoothRadioServices(), options: [ CBCentralManagerScanOptionAllowDuplicatesKey: true ])
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 ])
49 55
     }
50 56
     
51 57
     func discoveredMeter(peripheral: CBPeripheral, advertising advertismentData: [String : Any], rssi RSSI: NSNumber) {
52
-        guard let peripheralName = resolvedPeripheralName(for: peripheral, advertising: advertismentData) else {
53
-            return
54
-        }
55
-        guard let manufacturerData = resolvedManufacturerData(from: advertismentData), manufacturerData.count > 2 else {
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
+            )
56 73
             return
57 74
         }
58
-        
59
-        guard let model = Model.byPeripheralName[peripheralName] else {
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
+            )
60 86
             return
61 87
         }
62 88
         
63
-        let macAddress = MACAddress(from: manufacturerData.suffix(from: 2))
64 89
         let macAddressString = macAddress.description
65
-        appData.registerMeter(macAddress: macAddressString, modelName: model.canonicalName, advertisedName: peripheralName)
90
+        appData.registerMeter(macAddress: macAddressString, modelName: model.canonicalName, advertisedName: advertisedName)
66 91
         appData.noteMeterSeen(at: Date(), macAddress: macAddressString)
67 92
         
68 93
         if appData.meters[peripheral.identifier] == nil {
69
-            track("adding new USB Meter named '\(peripheralName)' with MAC Address: '\(macAddress)'")
70
-            let btSerial = BluetoothSerial(peripheral: peripheral, radio: model.radio, with: macAddress, managedBy: manager!, RSSI: RSSI.intValue)
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)
71 97
             var m = appData.meters
72 98
             let meter = Meter(model: model, with: btSerial)
73 99
             m[peripheral.identifier] = meter
@@ -85,6 +111,88 @@ class BluetoothManager : NSObject, ObservableObject {
85 111
             }
86 112
         }
87 113
     }
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
+    }
88 196
     
89 197
     private func resolvedPeripheralName(for peripheral: CBPeripheral, advertising advertismentData: [String : Any]) -> String? {
90 198
         let candidates = [
@@ -100,6 +208,10 @@ class BluetoothManager : NSObject, ObservableObject {
100 208
         
101 209
         return nil
102 210
     }
211
+
212
+    private func serviceUUIDs(from advertismentData: [String : Any], key: String) -> [CBUUID] {
213
+        advertismentData[key] as? [CBUUID] ?? []
214
+    }
103 215
     
104 216
     private func resolvedManufacturerData(from advertismentData: [String : Any]) -> Data? {
105 217
         if let data = advertismentData[CBAdvertisementDataManufacturerDataKey] as? Data {
+9 -3
USB Meter/Model/BluetoothRadio.swift
@@ -15,10 +15,13 @@ import CoreBluetooth
15 15
  - Code [HM10 Bluetooth Serial iOS](https://github.com/hoiberg/HM10-BluetoothSerial-iOS)
16 16
  # PW0316
17 17
  - Documentation [PW0316 BLE4.0 User Manual](http://www.phangwei.com/o/PW0316_User_Manual_V2.9.pdf)
18
+ # BT24-M
19
+ - Seen in newer TC66C units as a transparent BLE serial module on `FFE0`
18 20
  */
19 21
 enum BluetoothRadio : CaseIterable {
20 22
     case BT18
21 23
     case PW0316
24
+    case BT24M
22 25
     case UNKNOWN
23 26
 }
24 27
 
@@ -27,7 +30,8 @@ enum BluetoothRadio : CaseIterable {
27 30
  */
28 31
 var BluetoothRadioServicesUUIDS: [BluetoothRadio:[CBUUID]] = [
29 32
     .BT18 : [CBUUID(string: "FFE0")],
30
-    .PW0316 : [CBUUID(string: "FFE0"), CBUUID(string: "FFE5")]
33
+    .PW0316 : [CBUUID(string: "FFE0"), CBUUID(string: "FFE5")],
34
+    .BT24M : [CBUUID(string: "FFE0")]
31 35
 ]
32 36
 
33 37
 /**
@@ -40,7 +44,8 @@ var BluetoothRadioServicesUUIDS: [BluetoothRadio:[CBUUID]] = [
40 44
  */
41 45
 var BluetoothRadioNotifyUUIDs: [BluetoothRadio:[CBUUID]] = [
42 46
     .BT18 : [CBUUID(string: "FFE1")],
43
-    .PW0316 : [CBUUID(string: "FFE4")]
47
+    .PW0316 : [CBUUID(string: "FFE4")],
48
+    .BT24M : [CBUUID(string: "FFE1")]
44 49
 ]
45 50
 
46 51
 /**
@@ -52,7 +57,8 @@ var BluetoothRadioNotifyUUIDs: [BluetoothRadio:[CBUUID]] = [
52 57
  */
53 58
 var BluetoothRadioWriteUUIDs: [BluetoothRadio:[CBUUID]] = [
54 59
     .BT18 : [CBUUID(string: "FFE2"), CBUUID(string: "FFE1")],
55
-    .PW0316 : [CBUUID(string: "FFE9")]
60
+    .PW0316 : [CBUUID(string: "FFE9")],
61
+    .BT24M : [CBUUID(string: "FFE1")]
56 62
 ]
57 63
 
58 64
 /**
+2 -2
USB Meter/Model/BluetoothSerial.swift
@@ -304,7 +304,7 @@ extension BluetoothSerial : CBPeripheralDelegate {
304 304
             track( "Error: \(error!)" )
305 305
         }
306 306
         switch radio {
307
-        case .BT18:
307
+        case .BT18, .BT24M:
308 308
             for service in peripheral.services! {
309 309
                 switch service.uuid {
310 310
                 case CBUUID(string: "FFE0"):
@@ -336,7 +336,7 @@ extension BluetoothSerial : CBPeripheralDelegate {
336 336
         }
337 337
         track("\(String(describing: service.characteristics))")
338 338
         switch radio {
339
-        case .BT18:
339
+        case .BT18, .BT24M:
340 340
             updateBT18Characteristics(for: service)
341 341
         case .PW0316:
342 342
             updatePW0316Characteristics(for: service)
+9 -1
USB Meter/Model/MeterCapabilities.swift
@@ -110,6 +110,14 @@ extension Model {
110 110
         }
111 111
     )
112 112
 
113
+    static var knownPeripheralNames: [String] {
114
+        allCases.flatMap(\.peripheralNames).sorted()
115
+    }
116
+
117
+    static func model(forPeripheralName peripheralName: String) -> Model? {
118
+        byPeripheralName[peripheralName] ?? byPeripheralName[peripheralName.uppercased()]
119
+    }
120
+
113 121
     var radio: BluetoothRadio {
114 122
         switch self {
115 123
         case .UM25C, .UM34C:
@@ -126,7 +134,7 @@ extension Model {
126 134
         case .UM34C:
127 135
             return ["UM34C"]
128 136
         case .TC66C:
129
-            return ["TC66C", "PW0316"]
137
+            return ["TC66C", "BT24-M"]
130 138
         }
131 139
     }
132 140