rble 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +169 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +514 -0
  5. data/exe/rble +14 -0
  6. data/ext/macos_ble/Package.swift +20 -0
  7. data/ext/macos_ble/Sources/RBLEHelper/BLEManager.swift +783 -0
  8. data/ext/macos_ble/Sources/RBLEHelper/Protocol.swift +173 -0
  9. data/ext/macos_ble/Sources/RBLEHelper/main.swift +645 -0
  10. data/ext/macos_ble/extconf.rb +73 -0
  11. data/lib/rble/backend/base.rb +181 -0
  12. data/lib/rble/backend/bluez.rb +1279 -0
  13. data/lib/rble/backend/corebluetooth.rb +653 -0
  14. data/lib/rble/backend.rb +193 -0
  15. data/lib/rble/bluez/adapter.rb +169 -0
  16. data/lib/rble/bluez/async_call.rb +85 -0
  17. data/lib/rble/bluez/async_connection_operations.rb +492 -0
  18. data/lib/rble/bluez/async_gatt_operations.rb +249 -0
  19. data/lib/rble/bluez/async_introspection.rb +151 -0
  20. data/lib/rble/bluez/dbus_connection.rb +64 -0
  21. data/lib/rble/bluez/dbus_session.rb +344 -0
  22. data/lib/rble/bluez/device.rb +86 -0
  23. data/lib/rble/bluez/event_loop.rb +153 -0
  24. data/lib/rble/bluez/gatt_operation_queue.rb +129 -0
  25. data/lib/rble/bluez/pairing_agent.rb +132 -0
  26. data/lib/rble/bluez/pairing_session.rb +212 -0
  27. data/lib/rble/bluez/retry_policy.rb +55 -0
  28. data/lib/rble/bluez.rb +33 -0
  29. data/lib/rble/characteristic.rb +237 -0
  30. data/lib/rble/cli/adapter.rb +88 -0
  31. data/lib/rble/cli/characteristic_helpers.rb +154 -0
  32. data/lib/rble/cli/doctor.rb +309 -0
  33. data/lib/rble/cli/formatters/json.rb +122 -0
  34. data/lib/rble/cli/formatters/text.rb +157 -0
  35. data/lib/rble/cli/hex_dump.rb +48 -0
  36. data/lib/rble/cli/monitor.rb +129 -0
  37. data/lib/rble/cli/pair.rb +103 -0
  38. data/lib/rble/cli/paired.rb +22 -0
  39. data/lib/rble/cli/read.rb +55 -0
  40. data/lib/rble/cli/scan.rb +88 -0
  41. data/lib/rble/cli/show.rb +109 -0
  42. data/lib/rble/cli/status.rb +25 -0
  43. data/lib/rble/cli/unpair.rb +39 -0
  44. data/lib/rble/cli/value_parser.rb +211 -0
  45. data/lib/rble/cli/write.rb +196 -0
  46. data/lib/rble/cli.rb +152 -0
  47. data/lib/rble/company_ids.rb +90 -0
  48. data/lib/rble/connection.rb +539 -0
  49. data/lib/rble/device.rb +54 -0
  50. data/lib/rble/errors.rb +317 -0
  51. data/lib/rble/gatt/uuid_database.rb +395 -0
  52. data/lib/rble/scanner.rb +219 -0
  53. data/lib/rble/service.rb +41 -0
  54. data/lib/rble/tasks/check.rake +154 -0
  55. data/lib/rble/tasks/integration.rake +242 -0
  56. data/lib/rble/tasks.rb +8 -0
  57. data/lib/rble/version.rb +5 -0
  58. data/lib/rble.rb +62 -0
  59. metadata +120 -0
@@ -0,0 +1,783 @@
1
+ import Foundation
2
+ import CoreBluetooth
3
+
4
+ // MARK: - BLEManager
5
+
6
+ /// CoreBluetooth wrapper that manages Bluetooth scanning and device discovery.
7
+ /// This class handles all interaction with CoreBluetooth and emits events via the onEvent callback.
8
+ class BLEManager: NSObject, CBCentralManagerDelegate {
9
+ private var centralManager: CBCentralManager!
10
+ private var isScanning = false
11
+ private var allowDuplicates = false
12
+ private var serviceUUIDs: [CBUUID]?
13
+ private var reportedPeripherals: Set<String> = [] // Track already-reported UUIDs when !allowDuplicates
14
+
15
+ // Peripheral tracking
16
+ private var discoveredPeripherals: [String: CBPeripheral] = [:] // UUID -> peripheral
17
+ private var connectedPeripherals: [String: CBPeripheral] = [:]
18
+ private var pendingConnections: [String: (Result<Void, Error>) -> Void] = [:] // UUID -> completion
19
+ private var pendingDisconnects: [String: () -> Void] = [:]
20
+
21
+ // Service discovery tracking
22
+ private var pendingServiceDiscovery: [String: (Result<Void, Error>) -> Void] = [:]
23
+ private var pendingCharacteristicDiscovery: [String: Int] = [:] // UUID -> remaining services
24
+
25
+ // GATT operation tracking
26
+ private var pendingReads: [String: (Result<Data, Error>) -> Void] = [:] // deviceUUID:charUUID -> completion
27
+ private var pendingWrites: [String: (Result<Void, Error>) -> Void] = [:]
28
+ private var subscriptions: Set<String> = [] // deviceUUID:charUUID
29
+
30
+ /// Callback to send events to stdout
31
+ var onEvent: ((Event) -> Void)?
32
+
33
+ override init() {
34
+ super.init()
35
+ // Create manager on main queue for delegate callbacks
36
+ // nil queue = main queue, which is required for proper delegate callback handling
37
+ centralManager = CBCentralManager(delegate: self, queue: nil)
38
+ }
39
+
40
+ // MARK: - Public Methods
41
+
42
+ /// Start scanning for BLE peripherals
43
+ /// - Parameters:
44
+ /// - serviceUUIDs: Optional array of service UUID strings to filter by
45
+ /// - allowDuplicates: If true, receive repeated advertisements from the same device
46
+ /// - Throws: BLEError.notPoweredOn if Bluetooth is not enabled
47
+ func startScan(serviceUUIDs: [String]?, allowDuplicates: Bool) throws {
48
+ guard centralManager.state == .poweredOn else {
49
+ throw BLEError.notPoweredOn
50
+ }
51
+
52
+ self.allowDuplicates = allowDuplicates
53
+ self.serviceUUIDs = serviceUUIDs?.map { CBUUID(string: $0) }
54
+ self.reportedPeripherals = [] // Reset for new scan
55
+
56
+ let options: [String: Any] = [
57
+ CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates
58
+ ]
59
+
60
+ centralManager.scanForPeripherals(
61
+ withServices: self.serviceUUIDs,
62
+ options: options
63
+ )
64
+ isScanning = true
65
+ }
66
+
67
+ /// Stop scanning for BLE peripherals
68
+ func stopScan() {
69
+ centralManager.stopScan()
70
+ isScanning = false
71
+ }
72
+
73
+ /// Get current Bluetooth adapter state as a string
74
+ /// - Returns: String representation of the Bluetooth state
75
+ func getState() -> String {
76
+ switch centralManager.state {
77
+ case .unknown: return "unknown"
78
+ case .resetting: return "resetting"
79
+ case .unsupported: return "unsupported"
80
+ case .unauthorized: return "unauthorized"
81
+ case .poweredOff: return "powered_off"
82
+ case .poweredOn: return "powered_on"
83
+ @unknown default: return "unknown"
84
+ }
85
+ }
86
+
87
+ /// Check if Bluetooth is currently powered on
88
+ var isPoweredOn: Bool {
89
+ return centralManager.state == .poweredOn
90
+ }
91
+
92
+ /// Callback for state changes (used for waitForPoweredOn)
93
+ private var stateChangeCallback: ((CBManagerState) -> Void)?
94
+
95
+ /// Wait for Bluetooth to be powered on
96
+ /// - Parameters:
97
+ /// - timeout: Maximum time to wait in seconds
98
+ /// - completion: Called with true if powered on, false if timeout or not available
99
+ func waitForPoweredOn(timeout: Int = 5, completion: @escaping (Bool) -> Void) {
100
+ // Already powered on?
101
+ if centralManager.state == .poweredOn {
102
+ completion(true)
103
+ return
104
+ }
105
+
106
+ // Already in a terminal non-powered state?
107
+ if centralManager.state == .unsupported ||
108
+ centralManager.state == .unauthorized ||
109
+ centralManager.state == .poweredOff {
110
+ completion(false)
111
+ return
112
+ }
113
+
114
+ // Wait for state change
115
+ var completed = false
116
+ let timeoutWorkItem = DispatchWorkItem {
117
+ if !completed {
118
+ completed = true
119
+ self.stateChangeCallback = nil
120
+ completion(false)
121
+ }
122
+ }
123
+ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: timeoutWorkItem)
124
+
125
+ stateChangeCallback = { newState in
126
+ if !completed {
127
+ if newState == .poweredOn {
128
+ completed = true
129
+ timeoutWorkItem.cancel()
130
+ self.stateChangeCallback = nil
131
+ completion(true)
132
+ } else if newState == .unsupported || newState == .unauthorized || newState == .poweredOff {
133
+ completed = true
134
+ timeoutWorkItem.cancel()
135
+ self.stateChangeCallback = nil
136
+ completion(false)
137
+ }
138
+ // For .unknown or .resetting, keep waiting
139
+ }
140
+ }
141
+ }
142
+
143
+ /// Wait for Bluetooth state to be determined (not unknown or resetting)
144
+ /// - Parameters:
145
+ /// - timeout: Maximum time to wait in seconds
146
+ /// - completion: Called with true if state is now known, false if timeout
147
+ func waitForStateKnown(timeout: Int = 2, completion: @escaping (Bool) -> Void) {
148
+ // Already in a known state?
149
+ let state = centralManager.state
150
+ if state != .unknown && state != .resetting {
151
+ completion(true)
152
+ return
153
+ }
154
+
155
+ // Wait for state change
156
+ var completed = false
157
+ let timeoutWorkItem = DispatchWorkItem {
158
+ if !completed {
159
+ completed = true
160
+ self.stateChangeCallback = nil
161
+ completion(false)
162
+ }
163
+ }
164
+ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: timeoutWorkItem)
165
+
166
+ stateChangeCallback = { newState in
167
+ if !completed && newState != .unknown && newState != .resetting {
168
+ completed = true
169
+ timeoutWorkItem.cancel()
170
+ self.stateChangeCallback = nil
171
+ completion(true)
172
+ }
173
+ }
174
+ }
175
+
176
+ // MARK: - CBCentralManagerDelegate
177
+
178
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
179
+ // Notify any waiters
180
+ stateChangeCallback?(central.state)
181
+
182
+ let event = Event(
183
+ method: "state_changed",
184
+ params: ["state": AnyCodable(getState())]
185
+ )
186
+ onEvent?(event)
187
+ }
188
+
189
+ func centralManager(
190
+ _ central: CBCentralManager,
191
+ didDiscover peripheral: CBPeripheral,
192
+ advertisementData: [String: Any],
193
+ rssi RSSI: NSNumber
194
+ ) {
195
+ let uuid = peripheral.identifier.uuidString.uppercased()
196
+
197
+ // Store discovered peripheral for later connection
198
+ discoveredPeripherals[uuid] = peripheral
199
+
200
+ // When allowDuplicates is false, only report each peripheral once
201
+ if !allowDuplicates {
202
+ if reportedPeripherals.contains(uuid) {
203
+ return // Already reported this peripheral
204
+ }
205
+ reportedPeripherals.insert(uuid)
206
+ }
207
+
208
+ // Build device info dictionary
209
+ var params: [String: AnyCodable] = [
210
+ "uuid": AnyCodable(peripheral.identifier.uuidString),
211
+ "rssi": AnyCodable(RSSI.intValue)
212
+ ]
213
+
214
+ // Add device name if available (from peripheral or advertisement data)
215
+ if let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String {
216
+ params["name"] = AnyCodable(name)
217
+ }
218
+
219
+ // Parse advertised service UUIDs
220
+ if let advertServiceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] {
221
+ params["service_uuids"] = AnyCodable(advertServiceUUIDs.map { $0.uuidString })
222
+ }
223
+
224
+ // Parse manufacturer data
225
+ // First 2 bytes are company ID (little-endian), rest is payload
226
+ if let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data {
227
+ if mfgData.count >= 2 {
228
+ let companyId = Int(UInt16(mfgData[0]) | (UInt16(mfgData[1]) << 8))
229
+ let dataBytes = Array(mfgData.dropFirst(2)).map { Int($0) }
230
+ params["manufacturer_data"] = AnyCodable([
231
+ "company_id": companyId,
232
+ "data": dataBytes
233
+ ])
234
+ }
235
+ }
236
+
237
+ // Parse service data (map of service UUID -> data bytes)
238
+ if let serviceData = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] {
239
+ var sdDict: [String: [Int]] = [:]
240
+ for (uuid, data) in serviceData {
241
+ sdDict[uuid.uuidString] = Array(data).map { Int($0) }
242
+ }
243
+ params["service_data"] = AnyCodable(sdDict)
244
+ }
245
+
246
+ // Tx power level if advertised
247
+ if let txPower = advertisementData[CBAdvertisementDataTxPowerLevelKey] as? NSNumber {
248
+ params["tx_power"] = AnyCodable(txPower.intValue)
249
+ }
250
+
251
+ // Connectable status if available
252
+ if let connectable = advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber {
253
+ params["connectable"] = AnyCodable(connectable.boolValue)
254
+ }
255
+
256
+ let event = Event(method: "device_discovered", params: params)
257
+ onEvent?(event)
258
+ }
259
+
260
+ // MARK: - CBCentralManagerDelegate - Connection
261
+
262
+ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
263
+ let uuid = peripheral.identifier.uuidString.uppercased()
264
+ connectedPeripherals[uuid] = peripheral
265
+ peripheral.delegate = self
266
+
267
+ // Notify pending connection
268
+ if let completion = pendingConnections.removeValue(forKey: uuid) {
269
+ completion(.success(()))
270
+ }
271
+
272
+ // Emit event
273
+ let event = Event(method: "connected", params: ["uuid": AnyCodable(uuid)])
274
+ onEvent?(event)
275
+ }
276
+
277
+ func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
278
+ let uuid = peripheral.identifier.uuidString.uppercased()
279
+ if let completion = pendingConnections.removeValue(forKey: uuid) {
280
+ completion(.failure(error ?? BLEError.connectionFailed))
281
+ }
282
+ }
283
+
284
+ func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
285
+ let uuid = peripheral.identifier.uuidString.uppercased()
286
+ connectedPeripherals.removeValue(forKey: uuid)
287
+
288
+ // Map error to reason
289
+ let reason = mapDisconnectReason(error)
290
+
291
+ if let completion = pendingDisconnects.removeValue(forKey: uuid) {
292
+ completion()
293
+ }
294
+
295
+ let event = Event(method: "disconnected", params: [
296
+ "uuid": AnyCodable(uuid),
297
+ "reason": AnyCodable(reason),
298
+ "error": AnyCodable(error?.localizedDescription as Any)
299
+ ])
300
+ onEvent?(event)
301
+ }
302
+
303
+ // MARK: - Disconnect Reason Mapping
304
+
305
+ /// Map CoreBluetooth error to disconnect reason string
306
+ /// - Parameter error: The error from didDisconnectPeripheral
307
+ /// - Returns: String reason for disconnect event
308
+ private func mapDisconnectReason(_ error: Error?) -> String {
309
+ guard let error = error else {
310
+ return "user_requested"
311
+ }
312
+
313
+ if let cbError = error as? CBError {
314
+ switch cbError.code {
315
+ case .connectionTimeout:
316
+ return "timeout"
317
+ case .peripheralDisconnected:
318
+ return "remote_disconnect"
319
+ case .connectionFailed:
320
+ return "connection_failed"
321
+ default:
322
+ return "unknown"
323
+ }
324
+ }
325
+
326
+ // Check for NSError with CoreBluetooth domain
327
+ let nsError = error as NSError
328
+ if nsError.domain == CBErrorDomain {
329
+ switch nsError.code {
330
+ case 6: // CBErrorConnectionTimeout
331
+ return "timeout"
332
+ case 7: // CBErrorPeripheralDisconnected
333
+ return "remote_disconnect"
334
+ default:
335
+ return "unknown"
336
+ }
337
+ }
338
+
339
+ // Generic error - likely link loss
340
+ return "link_loss"
341
+ }
342
+
343
+ // MARK: - Connection Methods
344
+
345
+ /// Connect to a peripheral by UUID
346
+ /// - Parameters:
347
+ /// - uuid: The peripheral's UUID string
348
+ /// - completion: Callback with success/failure result
349
+ func connect(uuid: String, completion: @escaping (Result<Void, Error>) -> Void) {
350
+ // Normalize UUID to uppercase for consistent lookup
351
+ let normalizedUUID = uuid.uppercased()
352
+
353
+ guard let peripheral = discoveredPeripherals[normalizedUUID] else {
354
+ completion(.failure(BLEError.deviceNotFound))
355
+ return
356
+ }
357
+
358
+ guard centralManager.state == .poweredOn else {
359
+ completion(.failure(BLEError.notPoweredOn))
360
+ return
361
+ }
362
+
363
+ pendingConnections[normalizedUUID] = completion
364
+ centralManager.connect(peripheral, options: nil)
365
+ }
366
+
367
+ /// Disconnect from a peripheral
368
+ /// - Parameters:
369
+ /// - uuid: The peripheral's UUID string
370
+ /// - completion: Callback when disconnection completes
371
+ func disconnect(uuid: String, completion: @escaping () -> Void) {
372
+ let normalizedUUID = uuid.uppercased()
373
+ guard let peripheral = connectedPeripherals[normalizedUUID] else {
374
+ completion()
375
+ return
376
+ }
377
+
378
+ pendingDisconnects[normalizedUUID] = completion
379
+ centralManager.cancelPeripheralConnection(peripheral)
380
+ }
381
+
382
+ /// Check if a peripheral is connected
383
+ /// - Parameter uuid: The peripheral's UUID string
384
+ /// - Returns: True if connected
385
+ func isConnected(uuid: String) -> Bool {
386
+ return connectedPeripherals[uuid.uppercased()] != nil
387
+ }
388
+
389
+ // MARK: - GATT Operations
390
+
391
+ /// Normalize a UUID, converting 4-character short UUIDs to full 128-bit format
392
+ /// - Parameter uuid: The UUID string (either 4-char short or full 128-bit)
393
+ /// - Returns: The full 128-bit UUID string
394
+ private func normalizeUUID(_ uuid: String) -> String {
395
+ if uuid.count == 4 {
396
+ return "0000\(uuid.uppercased())-0000-1000-8000-00805F9B34FB"
397
+ }
398
+ return uuid.uppercased()
399
+ }
400
+
401
+ /// Find a characteristic by device, service, and characteristic UUIDs
402
+ /// - Parameters:
403
+ /// - deviceUUID: The peripheral's UUID string
404
+ /// - serviceUUID: The service UUID (short or full)
405
+ /// - charUUID: The characteristic UUID (short or full)
406
+ /// - Returns: The CBCharacteristic if found, nil otherwise
407
+ func findCharacteristic(deviceUUID: String, serviceUUID: String, charUUID: String) -> CBCharacteristic? {
408
+ let normalizedDeviceUUID = deviceUUID.uppercased()
409
+ guard let peripheral = connectedPeripherals[normalizedDeviceUUID],
410
+ let services = peripheral.services else {
411
+ return nil
412
+ }
413
+
414
+ // Find service (support both full and short UUID)
415
+ // Normalize both input UUID and CoreBluetooth UUID to full form for comparison
416
+ let normalizedServiceUUID = normalizeUUID(serviceUUID)
417
+ guard let service = services.first(where: {
418
+ normalizeUUID($0.uuid.uuidString) == normalizedServiceUUID
419
+ }) else {
420
+ return nil
421
+ }
422
+
423
+ // Find characteristic
424
+ // Normalize both input UUID and CoreBluetooth UUID to full form for comparison
425
+ let normalizedCharUUID = normalizeUUID(charUUID)
426
+ return service.characteristics?.first(where: {
427
+ normalizeUUID($0.uuid.uuidString) == normalizedCharUUID
428
+ })
429
+ }
430
+
431
+ /// Read a characteristic value
432
+ /// - Parameters:
433
+ /// - deviceUUID: The peripheral's UUID string
434
+ /// - serviceUUID: The service UUID (short or full)
435
+ /// - charUUID: The characteristic UUID (short or full)
436
+ /// - completion: Callback with the read data or error
437
+ func readCharacteristic(
438
+ deviceUUID: String,
439
+ serviceUUID: String,
440
+ charUUID: String,
441
+ completion: @escaping (Result<Data, Error>) -> Void
442
+ ) {
443
+ let normalizedDeviceUUID = deviceUUID.uppercased()
444
+ guard let peripheral = connectedPeripherals[normalizedDeviceUUID] else {
445
+ completion(.failure(BLEError.notConnected))
446
+ return
447
+ }
448
+
449
+ guard let characteristic = findCharacteristic(
450
+ deviceUUID: deviceUUID,
451
+ serviceUUID: serviceUUID,
452
+ charUUID: charUUID
453
+ ) else {
454
+ completion(.failure(BLEError.characteristicNotFound))
455
+ return
456
+ }
457
+
458
+ let key = "\(normalizedDeviceUUID):\(characteristic.uuid.uuidString.uppercased())"
459
+ pendingReads[key] = completion
460
+ peripheral.readValue(for: characteristic)
461
+ }
462
+
463
+ /// Write a value to a characteristic
464
+ /// - Parameters:
465
+ /// - deviceUUID: The peripheral's UUID string
466
+ /// - serviceUUID: The service UUID (short or full)
467
+ /// - charUUID: The characteristic UUID (short or full)
468
+ /// - data: The data to write
469
+ /// - withResponse: Whether to request a write response from the peripheral
470
+ /// - completion: Callback with success or error
471
+ func writeCharacteristic(
472
+ deviceUUID: String,
473
+ serviceUUID: String,
474
+ charUUID: String,
475
+ data: Data,
476
+ withResponse: Bool,
477
+ completion: @escaping (Result<Void, Error>) -> Void
478
+ ) {
479
+ let normalizedDeviceUUID = deviceUUID.uppercased()
480
+ guard let peripheral = connectedPeripherals[normalizedDeviceUUID] else {
481
+ completion(.failure(BLEError.notConnected))
482
+ return
483
+ }
484
+
485
+ guard let characteristic = findCharacteristic(
486
+ deviceUUID: deviceUUID,
487
+ serviceUUID: serviceUUID,
488
+ charUUID: charUUID
489
+ ) else {
490
+ completion(.failure(BLEError.characteristicNotFound))
491
+ return
492
+ }
493
+
494
+ let writeType: CBCharacteristicWriteType = withResponse ? .withResponse : .withoutResponse
495
+
496
+ if withResponse {
497
+ let key = "\(normalizedDeviceUUID):\(characteristic.uuid.uuidString.uppercased())"
498
+ pendingWrites[key] = completion
499
+ }
500
+
501
+ peripheral.writeValue(data, for: characteristic, type: writeType)
502
+
503
+ if !withResponse {
504
+ // No response expected, complete immediately
505
+ completion(.success(()))
506
+ }
507
+ }
508
+
509
+ /// Subscribe to notifications for a characteristic
510
+ /// - Parameters:
511
+ /// - deviceUUID: The peripheral's UUID string
512
+ /// - serviceUUID: The service UUID (short or full)
513
+ /// - charUUID: The characteristic UUID (short or full)
514
+ /// - completion: Callback when subscription is confirmed or fails
515
+ func subscribe(
516
+ deviceUUID: String,
517
+ serviceUUID: String,
518
+ charUUID: String,
519
+ completion: @escaping (Result<Void, Error>) -> Void
520
+ ) {
521
+ let normalizedDeviceUUID = deviceUUID.uppercased()
522
+ guard let peripheral = connectedPeripherals[normalizedDeviceUUID] else {
523
+ completion(.failure(BLEError.notConnected))
524
+ return
525
+ }
526
+
527
+ guard let characteristic = findCharacteristic(
528
+ deviceUUID: deviceUUID,
529
+ serviceUUID: serviceUUID,
530
+ charUUID: charUUID
531
+ ) else {
532
+ completion(.failure(BLEError.characteristicNotFound))
533
+ return
534
+ }
535
+
536
+ let key = "\(normalizedDeviceUUID):\(characteristic.uuid.uuidString.uppercased())"
537
+ subscriptions.insert(key)
538
+
539
+ // setNotifyValue completion comes via didUpdateNotificationState
540
+ pendingWrites[key] = completion
541
+ peripheral.setNotifyValue(true, for: characteristic)
542
+ }
543
+
544
+ /// Unsubscribe from notifications for a characteristic
545
+ /// - Parameters:
546
+ /// - deviceUUID: The peripheral's UUID string
547
+ /// - serviceUUID: The service UUID (short or full)
548
+ /// - charUUID: The characteristic UUID (short or full)
549
+ func unsubscribe(
550
+ deviceUUID: String,
551
+ serviceUUID: String,
552
+ charUUID: String
553
+ ) {
554
+ let normalizedDeviceUUID = deviceUUID.uppercased()
555
+ guard let peripheral = connectedPeripherals[normalizedDeviceUUID],
556
+ let characteristic = findCharacteristic(
557
+ deviceUUID: deviceUUID,
558
+ serviceUUID: serviceUUID,
559
+ charUUID: charUUID
560
+ ) else {
561
+ return
562
+ }
563
+
564
+ let key = "\(normalizedDeviceUUID):\(characteristic.uuid.uuidString.uppercased())"
565
+ subscriptions.remove(key)
566
+ peripheral.setNotifyValue(false, for: characteristic)
567
+ }
568
+
569
+ // MARK: - Service Discovery Methods
570
+
571
+ /// Discover services for a connected peripheral
572
+ /// - Parameters:
573
+ /// - uuid: The peripheral's UUID string
574
+ /// - completion: Callback with success/failure result
575
+ func discoverServices(uuid: String, completion: @escaping (Result<Void, Error>) -> Void) {
576
+ let normalizedUUID = uuid.uppercased()
577
+ guard let peripheral = connectedPeripherals[normalizedUUID] else {
578
+ completion(.failure(BLEError.notConnected))
579
+ return
580
+ }
581
+
582
+ pendingServiceDiscovery[normalizedUUID] = completion
583
+ peripheral.discoverServices(nil) // Discover all services
584
+ }
585
+
586
+ /// Get discovered services and characteristics for a connected peripheral
587
+ /// - Parameter uuid: The peripheral's UUID string
588
+ /// - Returns: Array of service dictionaries with nested characteristics, or nil if not found
589
+ func getServices(uuid: String) -> [[String: Any]]? {
590
+ let normalizedUUID = uuid.uppercased()
591
+ guard let peripheral = connectedPeripherals[normalizedUUID],
592
+ let services = peripheral.services else {
593
+ return nil
594
+ }
595
+
596
+ return services.map { service in
597
+ var serviceDict: [String: Any] = [
598
+ "uuid": service.uuid.uuidString,
599
+ "primary": service.isPrimary
600
+ ]
601
+
602
+ let characteristics: [[String: Any]] = (service.characteristics ?? []).map { char in
603
+ [
604
+ "uuid": char.uuid.uuidString,
605
+ "properties": characteristicPropertiesToFlags(char.properties),
606
+ "service_uuid": service.uuid.uuidString
607
+ ]
608
+ }
609
+ serviceDict["characteristics"] = characteristics
610
+
611
+ return serviceDict
612
+ }
613
+ }
614
+
615
+ /// Convert CBCharacteristicProperties to array of string flags
616
+ private func characteristicPropertiesToFlags(_ properties: CBCharacteristicProperties) -> [String] {
617
+ var flags: [String] = []
618
+ if properties.contains(.read) { flags.append("read") }
619
+ if properties.contains(.write) { flags.append("write") }
620
+ if properties.contains(.writeWithoutResponse) { flags.append("write-without-response") }
621
+ if properties.contains(.notify) { flags.append("notify") }
622
+ if properties.contains(.indicate) { flags.append("indicate") }
623
+ if properties.contains(.broadcast) { flags.append("broadcast") }
624
+ if properties.contains(.authenticatedSignedWrites) { flags.append("authenticated-signed-writes") }
625
+ if properties.contains(.extendedProperties) { flags.append("extended-properties") }
626
+ return flags
627
+ }
628
+ }
629
+
630
+ // MARK: - CBPeripheralDelegate
631
+
632
+ extension BLEManager: CBPeripheralDelegate {
633
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
634
+ let uuid = peripheral.identifier.uuidString.uppercased()
635
+
636
+ if let error = error {
637
+ if let completion = pendingServiceDiscovery.removeValue(forKey: uuid) {
638
+ completion(.failure(error))
639
+ }
640
+ return
641
+ }
642
+
643
+ // Discover characteristics for each service
644
+ guard let services = peripheral.services, !services.isEmpty else {
645
+ // No services found, complete immediately
646
+ if let completion = pendingServiceDiscovery.removeValue(forKey: uuid) {
647
+ completion(.success(()))
648
+ }
649
+ return
650
+ }
651
+
652
+ pendingCharacteristicDiscovery[uuid] = services.count
653
+
654
+ for service in services {
655
+ peripheral.discoverCharacteristics(nil, for: service)
656
+ }
657
+ }
658
+
659
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
660
+ let uuid = peripheral.identifier.uuidString.uppercased()
661
+
662
+ // Decrement pending count
663
+ if var remaining = pendingCharacteristicDiscovery[uuid] {
664
+ remaining -= 1
665
+ pendingCharacteristicDiscovery[uuid] = remaining
666
+
667
+ // All services discovered?
668
+ if remaining == 0 {
669
+ pendingCharacteristicDiscovery.removeValue(forKey: uuid)
670
+ if let completion = pendingServiceDiscovery.removeValue(forKey: uuid) {
671
+ completion(.success(()))
672
+ }
673
+ }
674
+ }
675
+ }
676
+
677
+ // MARK: - GATT Operation Callbacks
678
+
679
+ func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
680
+ let deviceUUID = peripheral.identifier.uuidString.uppercased()
681
+ let charUUID = characteristic.uuid.uuidString.uppercased()
682
+ let key = "\(deviceUUID):\(charUUID)"
683
+
684
+ // Check if this is a read response
685
+ if let completion = pendingReads.removeValue(forKey: key) {
686
+ if let error = error {
687
+ completion(.failure(error))
688
+ } else if let value = characteristic.value {
689
+ completion(.success(value))
690
+ } else {
691
+ completion(.success(Data()))
692
+ }
693
+ return
694
+ }
695
+
696
+ // Otherwise it's a notification
697
+ if subscriptions.contains(key) {
698
+ let value = characteristic.value ?? Data()
699
+ let event = Event(
700
+ method: "notification",
701
+ params: [
702
+ "device_uuid": AnyCodable(deviceUUID),
703
+ "service_uuid": AnyCodable(characteristic.service?.uuid.uuidString.uppercased() ?? ""),
704
+ "char_uuid": AnyCodable(charUUID),
705
+ "value": AnyCodable(Array(value).map { Int($0) })
706
+ ]
707
+ )
708
+ onEvent?(event)
709
+ }
710
+ }
711
+
712
+ func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
713
+ let key = "\(peripheral.identifier.uuidString.uppercased()):\(characteristic.uuid.uuidString.uppercased())"
714
+ if let completion = pendingWrites.removeValue(forKey: key) {
715
+ if let error = error {
716
+ completion(.failure(error))
717
+ } else {
718
+ completion(.success(()))
719
+ }
720
+ }
721
+ }
722
+
723
+ func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
724
+ let key = "\(peripheral.identifier.uuidString.uppercased()):\(characteristic.uuid.uuidString.uppercased())"
725
+ if let completion = pendingWrites.removeValue(forKey: key) {
726
+ if let error = error {
727
+ completion(.failure(error))
728
+ } else {
729
+ completion(.success(()))
730
+ }
731
+ }
732
+ }
733
+ }
734
+
735
+ // MARK: - BLE Errors
736
+
737
+ /// Errors specific to BLE operations
738
+ enum BLEError: Error {
739
+ case notPoweredOn
740
+ case notConnected
741
+ case timeout
742
+ case invalidUUID(String)
743
+ case deviceNotFound
744
+ case connectionFailed
745
+ case serviceDiscoveryFailed
746
+ case characteristicNotFound
747
+
748
+ var localizedDescription: String {
749
+ switch self {
750
+ case .notPoweredOn:
751
+ return "Bluetooth not powered on"
752
+ case .notConnected:
753
+ return "Not connected to device"
754
+ case .timeout:
755
+ return "Operation timed out"
756
+ case .invalidUUID(let uuid):
757
+ return "Invalid UUID: \(uuid)"
758
+ case .deviceNotFound:
759
+ return "Device not found (must scan first)"
760
+ case .connectionFailed:
761
+ return "Connection failed"
762
+ case .serviceDiscoveryFailed:
763
+ return "Service discovery failed"
764
+ case .characteristicNotFound:
765
+ return "Characteristic not found"
766
+ }
767
+ }
768
+ }
769
+
770
+ // MARK: - BLE Error Codes (for JSON-RPC responses)
771
+
772
+ /// Application-specific error codes (outside JSON-RPC reserved range)
773
+ enum BLEErrorCode {
774
+ static let notPoweredOn = -1
775
+ static let notConnected = -2
776
+ static let timeout = -3
777
+ static let invalidUUID = -4
778
+ static let operationFailed = -5
779
+ static let deviceNotFound = -6
780
+ static let connectionFailed = -7
781
+ static let serviceDiscoveryFailed = -8
782
+ static let characteristicNotFound = -9
783
+ }