|
Bogdan Timofte
authored
2 weeks ago
|
1
|
# Bluetooth Discovery
|
|
|
2
|
|
|
|
3
|
Mecanismul de descoperire şi conectare a dispozitivelor Bluetooth.
|
|
|
4
|
|
|
|
5
|
## Arhitectură
|
|
|
6
|
|
|
|
7
|
### Components
|
|
|
8
|
|
|
|
9
|
1. **BluetoothManager**: coordonează CBCentralManager şi descoperirile
|
|
|
10
|
2. **BluetoothRadio**: interfață spre Core Bluetooth low-level
|
|
|
11
|
3. **BluetoothSerial**: comunicare pe caracteristici UART
|
|
|
12
|
4. **MeterCapabilities**: detectează tip meter (UM25C, UM34C, TC66C)
|
|
|
13
|
|
|
|
14
|
## Scanning
|
|
|
15
|
|
|
|
16
|
### Lifecycle
|
|
|
17
|
|
|
|
18
|
```
|
|
|
19
|
User opens app
|
|
|
20
|
↓
|
|
|
21
|
SceneDelegate calls appData.activateCloudDeviceSync()
|
|
|
22
|
↓
|
|
|
23
|
BluetoothManager starts CBCentralManager
|
|
|
24
|
↓
|
|
|
25
|
CBCentralManager begins scanning
|
|
|
26
|
↓
|
|
|
27
|
didDiscoverPeripheral → filter by service UUIDs
|
|
|
28
|
↓
|
|
|
29
|
Check device profile (class, capabilities)
|
|
|
30
|
↓
|
|
|
31
|
Add to discoveredMeters list (UI refresh)
|
|
|
32
|
```
|
|
|
33
|
|
|
|
34
|
### Service UUIDs
|
|
|
35
|
|
|
|
36
|
```swift
|
|
|
37
|
let targetServices = [
|
|
|
38
|
CBUUID(string: "FFF0"), // UM25C, UM34C
|
|
|
39
|
CBUUID(string: "180D"), // TC66C (generic service)
|
|
|
40
|
]
|
|
|
41
|
```
|
|
|
42
|
|
|
|
43
|
- **MUST**: Scan cu service UUIDs specifice (nu scan generic)
|
|
|
44
|
- **SHOULD**: Filter prin manufacturer data dacă posibil
|
|
|
45
|
- **REASON**: Reduce energy drain, reduce noise
|
|
|
46
|
|
|
|
47
|
### Advertisement parsing
|
|
|
48
|
|
|
|
49
|
Meter advertise-ază:
|
|
|
50
|
- Device name: de ex "UM25C-XXXX" sau "TC66-XXXX"
|
|
|
51
|
- Services: FFF0 (UM series) sau 180D (TC66)
|
|
|
52
|
- Manufacturer data: RDTech identifier
|
|
|
53
|
|
|
|
54
|
```swift
|
|
|
55
|
if let manufacturerData = advertisement[CBAdvertisementDataManufacturerDataKey] as? Data {
|
|
|
56
|
let manufacturerId = manufacturerData.withUnsafeBytes { $0.load(as: UInt16.self) }
|
|
|
57
|
if manufacturerId == 0x5449 { // RDTech
|
|
|
58
|
// Possible UM meter
|
|
|
59
|
}
|
|
|
60
|
}
|
|
|
61
|
```
|
|
|
62
|
|
|
|
63
|
- **SHOULD**: Parse manufacturer data pentru identificare rapidă
|
|
|
64
|
- **MAY**: Use device name ca fallback (less reliable)
|
|
|
65
|
|
|
|
66
|
### Device identification
|
|
|
67
|
|
|
|
68
|
```swift
|
|
|
69
|
func identifyMeterType(name: String, services: [CBUUID]) -> Model {
|
|
|
70
|
if name.contains("UM25C") || services.contains(FFF0) {
|
|
|
71
|
return .UM25C
|
|
|
72
|
} else if name.contains("UM34C") || services.contains(FFF0) {
|
|
|
73
|
return .UM34C
|
|
|
74
|
} else if name.contains("TC66") || services.contains(180D) {
|
|
|
75
|
return .TC66C
|
|
|
76
|
}
|
|
|
77
|
return .unknown
|
|
|
78
|
}
|
|
|
79
|
```
|
|
|
80
|
|
|
|
81
|
- **MUST**: Trebuie să identific corect tipul de meter
|
|
|
82
|
- **SHOULD**: Use name + services (redundant checks)
|
|
|
83
|
- **MUST**: Fallback la `.unknown` dacă uncertain
|
|
|
84
|
|
|
|
85
|
## Connection
|
|
|
86
|
|
|
|
87
|
### Initiation
|
|
|
88
|
|
|
|
89
|
```
|
|
|
90
|
User taps meter in list
|
|
|
91
|
↓
|
|
|
92
|
AppData calls meter.connect()
|
|
|
93
|
↓
|
|
|
94
|
BluetoothManager calls cbCentralManager.connect(peripheral)
|
|
|
95
|
↓
|
|
|
96
|
didConnect: state → peripheralConnected
|
|
|
97
|
↓
|
|
|
98
|
Discover services & characteristics
|
|
|
99
|
```
|
|
|
100
|
|
|
|
101
|
### Service/Characteristic discovery
|
|
|
102
|
|
|
|
103
|
```swift
|
|
|
104
|
// For UM series (FFF0)
|
|
|
105
|
let targetService = CBUUID(string: "FFF0")
|
|
|
106
|
let readCharacteristic = CBUUID(string: "FFF1") // read measurements
|
|
|
107
|
let writeCharacteristic = CBUUID(string: "FFF2") // send commands
|
|
|
108
|
|
|
|
109
|
// For TC66 (180D)
|
|
|
110
|
let heartRate = CBUUID(string: "2A37") // uses standard HRM characteristic
|
|
|
111
|
```
|
|
|
112
|
|
|
|
113
|
- **MUST**: Discover services → discover characteristics (ordered)
|
|
|
114
|
- **MUST**: Find expected characteristics, fail if not found
|
|
|
115
|
- **SHOULD**: Subscribe to notifications pentru updates
|
|
|
116
|
- **TIMEOUT**: 5s max pentru service discovery
|
|
|
117
|
|
|
|
118
|
### State transitions
|
|
|
119
|
|
|
|
120
|
```
|
|
|
121
|
peripheralConnected
|
|
|
122
|
↓
|
|
|
123
|
discoveringServices (discovering FFF0 / 180D)
|
|
|
124
|
↓
|
|
|
125
|
discoveringCharacteristics (discovering FFF1, FFF2 / 2A37)
|
|
|
126
|
↓
|
|
|
127
|
peripheralReady (services + characteristics found)
|
|
|
128
|
↓
|
|
|
129
|
comunicating ↔ dataIsAvailable (steady state)
|
|
|
130
|
```
|
|
|
131
|
|
|
|
132
|
- **MUST**: Stare monotonă crescătoare (no rollback)
|
|
|
133
|
- **SHOULD**: Log state transitions
|
|
|
134
|
- **MUST**: Fail gracefully dacă characteristics nu sunt găsite
|
|
|
135
|
|
|
|
136
|
## Communication
|
|
|
137
|
|
|
|
138
|
### Measurement requests
|
|
|
139
|
|
|
|
140
|
**UM series** (UM25C, UM34C):
|
|
|
141
|
```swift
|
|
|
142
|
// Send command to FFF2 (write characteristic)
|
|
|
143
|
let command = UMProtocol.buildMeasurementRequest()
|
|
|
144
|
peripheral.writeValue(command, for: writeCharacteristic, type: .withResponse)
|
|
|
145
|
|
|
|
146
|
// Receive on FFF1 (read characteristic)
|
|
|
147
|
// Parse payload (voltage, current, power, temperature, etc.)
|
|
|
148
|
let measurement = UMProtocol.parseMeasurement(data)
|
|
|
149
|
```
|
|
|
150
|
|
|
|
151
|
**TC66C**:
|
|
|
152
|
```swift
|
|
|
153
|
// Uses standard HRM (Heart Rate Measurement) characteristic
|
|
|
154
|
// Value format: flags byte + heart_rate_value (2 bytes)
|
|
|
155
|
// Repurposed for power data: MSB = watts, LSB = amps (approximation)
|
|
|
156
|
let measurement = TC66Protocol.parseMeasurement(data)
|
|
|
157
|
```
|
|
|
158
|
|
|
|
159
|
- **MUST**: Parse protocol-specific payloads
|
|
|
160
|
- **MUST**: Validate checksum (if applicable)
|
|
|
161
|
- **SHOULD**: Handle invalid/truncated payloads gracefully
|
|
|
162
|
- **TIMEOUT**: 3s per measurement request
|
|
|
163
|
|
|
|
164
|
### Write commands
|
|
|
165
|
|
|
|
166
|
UM series supports commands:
|
|
|
167
|
- Request measurement: `0x00 0xF0 0xA0 0x1B` (+ checksum)
|
|
|
168
|
- Set time: `0x02 ...` (timestamp)
|
|
|
169
|
- Set calibration: (advanced)
|
|
|
170
|
|
|
|
171
|
```swift
|
|
|
172
|
peripheral.writeValue(
|
|
|
173
|
command,
|
|
|
174
|
for: writeCharacteristic,
|
|
|
175
|
type: .withResponse // MUST: wait for ACK
|
|
|
176
|
)
|
|
|
177
|
```
|
|
|
178
|
|
|
|
179
|
- **MUST**: Use `.withResponse` pentru command-uri critice
|
|
|
180
|
- **MAY**: Use `.withoutResponse` pentru bulk writes
|
|
|
181
|
- **SHOULD**: Validate response ACK
|
|
|
182
|
|
|
|
183
|
## Disconnection handling
|
|
|
184
|
|
|
|
185
|
### Intentional disconnect
|
|
|
186
|
|
|
|
187
|
```
|
|
|
188
|
User taps "Disconnect"
|
|
|
189
|
↓
|
|
|
190
|
meter.disconnect()
|
|
|
191
|
↓
|
|
|
192
|
BluetoothManager calls cbCentralManager.cancelPeripheralConnection()
|
|
|
193
|
↓
|
|
|
194
|
didDisconnect: state → peripheralNotConnected
|
|
|
195
|
```
|
|
|
196
|
|
|
|
197
|
- **MUST**: Anulează pending operations
|
|
|
198
|
- **MUST**: Anulează auto-reconnect logic
|
|
|
199
|
- **MUST**: Eliberează callbacks
|
|
|
200
|
|
|
|
201
|
### Unintentional disconnect (BT drop)
|
|
|
202
|
|
|
|
203
|
```
|
|
|
204
|
BT device disconnects (out of range / powered off / interference)
|
|
|
205
|
↓
|
|
|
206
|
didDisconnect event (didDisconnect reason: optional)
|
|
|
207
|
↓
|
|
|
208
|
state → peripheralNotConnected
|
|
|
209
|
↓
|
|
|
210
|
Auto-reconnect logic starts (with backoff)
|
|
|
211
|
```
|
|
|
212
|
|
|
|
213
|
- **MUST**: Detecta unintentional drops (log reason dacă available)
|
|
|
214
|
- **SHOULD**: Incepe auto-reconnect cu backoff exponential
|
|
|
215
|
- **MUST**: Anulează dacă user disconnect manual (flag)
|
|
|
216
|
|
|
|
217
|
## Auto-reconnect
|
|
|
218
|
|
|
|
219
|
### Backoff strategy
|
|
|
220
|
|
|
|
221
|
```
|
|
|
222
|
Attempt 1: 1s delay
|
|
|
223
|
Attempt 2: 2s delay
|
|
|
224
|
Attempt 3: 4s delay
|
|
|
225
|
Attempt 4: 8s delay
|
|
|
226
|
Attempt 5: 16s delay
|
|
|
227
|
Attempt 6: 32s delay
|
|
|
228
|
Attempt 7+: 60s delay (capped)
|
|
|
229
|
Max attempts: 3
|
|
|
230
|
```
|
|
|
231
|
|
|
|
232
|
- **MUST**: Exponential backoff (2^n, capped at 60s)
|
|
|
233
|
- **MUST**: Max 3 consecutive retry-uri
|
|
|
234
|
- **MUST**: Stop retry dacă user disconnect manual
|
|
|
235
|
- **SHOULD**: Log fiecare retry attempt
|
|
|
236
|
|
|
|
237
|
### Trigger conditions
|
|
|
238
|
|
|
|
239
|
- **MUST**: Activate automatic reconnect doar dacă user conectase anterior
|
|
|
240
|
- **MUST**: Disable dacă user disconnect manual
|
|
|
241
|
- **MUST**: Disable dacă app goes background > 10 min
|
|
|
242
|
- **SHOULD**: Resume reconnect dacă app returns foreground
|
|
|
243
|
|
|
|
244
|
## Testing
|
|
|
245
|
|
|
|
246
|
### Unit tests
|
|
|
247
|
|
|
|
248
|
```swift
|
|
|
249
|
test_scanFiltersByServiceUUIDs()
|
|
|
250
|
test_deviceIdentification_UM25C()
|
|
|
251
|
test_deviceIdentification_UM34C()
|
|
|
252
|
test_deviceIdentification_TC66C()
|
|
|
253
|
test_connectionStateTransitions()
|
|
|
254
|
test_serviceDiscovery_UM25C()
|
|
|
255
|
test_characteristicDiscovery_UM25C()
|
|
|
256
|
test_measurementParsing_ValidPayload()
|
|
|
257
|
test_measurementParsing_InvalidPayload()
|
|
|
258
|
test_disconnectCleansUp()
|
|
|
259
|
test_unintentionalDropDetected()
|
|
|
260
|
test_autoReconnectBackoff_Exponential()
|
|
|
261
|
test_autoReconnectStops_OnManualDisconnect()
|
|
|
262
|
```
|
|
|
263
|
|
|
|
264
|
### Integration tests
|
|
|
265
|
|
|
|
266
|
- [ ] Scan detects available meters
|
|
|
267
|
- [ ] Device type identified correctly
|
|
|
268
|
- [ ] Connect → service discover → ready (full flow)
|
|
|
269
|
- [ ] Measurement received and parsed
|
|
|
270
|
- [ ] Unintentional drop detected + reconnect
|
|
|
271
|
- [ ] Auto-reconnect respects backoff timing
|
|
|
272
|
- [ ] Manual disconnect stops auto-reconnect
|
|
|
273
|
|
|
|
274
|
## Error handling
|
|
|
275
|
|
|
|
276
|
### Scan errors
|
|
|
277
|
|
|
|
278
|
```
|
|
|
279
|
Error: CBError.unknown
|
|
|
280
|
Error: CBError.managerStatePoweredOff
|
|
|
281
|
Error: CBError.invalidParameters
|
|
|
282
|
```
|
|
|
283
|
|
|
|
284
|
Handling:
|
|
|
285
|
- Retry scan periodically
|
|
|
286
|
- Notify UI: "Bluetooth unavailable"
|
|
|
287
|
|
|
|
288
|
### Connection errors
|
|
|
289
|
|
|
|
290
|
```
|
|
|
291
|
Error: peripheral not found
|
|
|
292
|
→ Retry with backoff
|
|
|
293
|
|
|
|
294
|
Error: timeout (no services found)
|
|
|
295
|
→ Disconnect + retry
|
|
|
296
|
|
|
|
297
|
Error: security/pairing required
|
|
|
298
|
→ Notify user: "Pair device in Settings"
|
|
|
299
|
```
|
|
|
300
|
|
|
|
301
|
### Communication errors
|
|
|
302
|
|
|
|
303
|
```
|
|
|
304
|
Error: write failed
|
|
|
305
|
→ Retry measurement request
|
|
|
306
|
|
|
|
307
|
Error: invalid payload
|
|
|
308
|
→ Log error, skip measurement
|
|
|
309
|
|
|
|
310
|
Error: characteristic not found
|
|
|
311
|
→ Disconnect + mark incompatible
|
|
|
312
|
```
|
|
|
313
|
|
|
|
314
|
## Dependencies
|
|
|
315
|
|
|
|
316
|
- `CoreBluetooth`: CBCentralManager, CBPeripheral
|
|
|
317
|
- `UMProtocol`: payload parsing for UM series
|
|
|
318
|
- `TC66Protocol`: payload parsing for TC66C
|
|
|
319
|
- `MeterCapabilities`: device type detection
|
|
|
320
|
- `AppData`: orchestration
|
|
|
321
|
|
|
|
322
|
## References
|
|
|
323
|
|
|
|
324
|
- [UMProtocol.swift](../../USB%20Meter/Model/UMProtocol.swift)
|
|
|
325
|
- [TC66Protocol.swift](../../USB%20Meter/Model/TC66Protocol.swift)
|
|
|
326
|
- [BluetoothManager.swift](../../USB%20Meter/Model/BluetoothManager.swift)
|
|
|
327
|
- [External documentation](https://sigrok.org/wiki/RDTech_UM_series)
|
|
|
328
|
- [TC66C reverse-engineering notes](../Research%20Resources/Payload%20Notes/TC66C%20Transport%20and%20Payload%20Working%20Note.md)
|