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,645 @@
1
+ import Foundation
2
+
3
+ /// RBLEHelper - macOS CoreBluetooth bridge for rble gem
4
+ ///
5
+ /// Communication protocol:
6
+ /// - Reads JSON requests from stdin (one per line)
7
+ /// - Writes JSON responses to stdout (one per line)
8
+ /// - Async events (device_discovered, state_changed) written to stdout as JSON lines
9
+ /// - All non-JSON output goes to stderr
10
+
11
+ // MARK: - Global State
12
+
13
+ /// Shared BLE manager instance
14
+ let bleManager = BLEManager()
15
+
16
+ // MARK: - Output Functions
17
+
18
+ /// Write a response to stdout as a single JSON line (thread-safe)
19
+ func writeResponse(_ response: Response) {
20
+ let encoder = JSONEncoder()
21
+ encoder.outputFormatting = [] // Compact output, no pretty printing
22
+ do {
23
+ let jsonData = try encoder.encode(response)
24
+ if let jsonString = String(data: jsonData, encoding: .utf8) {
25
+ print(jsonString)
26
+ fflush(stdout)
27
+ }
28
+ } catch {
29
+ // If encoding fails, write a minimal error response
30
+ fputs("{\"id\":-1,\"error\":{\"code\":-32603,\"message\":\"Internal error: failed to encode response\"}}\n", stdout)
31
+ fflush(stdout)
32
+ }
33
+ }
34
+
35
+ /// Write an event to stdout as a single JSON line (thread-safe)
36
+ func writeEvent(_ event: Event) {
37
+ let encoder = JSONEncoder()
38
+ encoder.outputFormatting = [] // Compact output, no pretty printing
39
+ do {
40
+ let jsonData = try encoder.encode(event)
41
+ if let jsonString = String(data: jsonData, encoding: .utf8) {
42
+ print(jsonString)
43
+ fflush(stdout)
44
+ }
45
+ } catch {
46
+ fputs("{\"method\":\"error\",\"params\":{\"message\":\"Failed to encode event\"}}\n", stdout)
47
+ fflush(stdout)
48
+ }
49
+ }
50
+
51
+ /// Create an error response for parse errors (no valid request ID available)
52
+ func createParseErrorResponse() -> Response {
53
+ return Response.error(
54
+ id: -1,
55
+ code: ErrorCode.parseError,
56
+ message: "Parse error: invalid JSON"
57
+ )
58
+ }
59
+
60
+ // MARK: - Request Handlers
61
+
62
+ /// Handle a parsed request and return a response
63
+ /// For sync methods, returns a Response directly
64
+ /// For async methods, returns nil and calls writeResponse when complete
65
+ func handleRequest(_ request: Request) -> Response? {
66
+ switch request.method {
67
+ case "adapters":
68
+ handleAdapters(request)
69
+ return nil // Response sent asynchronously after state check
70
+ case "scan_start":
71
+ handleScanStart(request)
72
+ return nil // Response sent asynchronously
73
+ case "scan_stop":
74
+ return handleScanStop(request)
75
+ case "connect":
76
+ handleConnect(request)
77
+ return nil // Response sent asynchronously
78
+ case "disconnect":
79
+ handleDisconnect(request)
80
+ return nil // Response sent asynchronously
81
+ case "discover_services":
82
+ handleDiscoverServices(request)
83
+ return nil // Response sent asynchronously
84
+ case "read_characteristic":
85
+ handleReadCharacteristic(request)
86
+ return nil // Response sent asynchronously
87
+ case "write_characteristic":
88
+ handleWriteCharacteristic(request)
89
+ return nil // Response sent asynchronously
90
+ case "subscribe":
91
+ handleSubscribe(request)
92
+ return nil // Response sent asynchronously
93
+ case "unsubscribe":
94
+ return handleUnsubscribe(request)
95
+ default:
96
+ return Response.error(
97
+ id: request.id,
98
+ code: ErrorCode.methodNotFound,
99
+ message: "Method not found: \(request.method)"
100
+ )
101
+ }
102
+ }
103
+
104
+ /// Handle the "adapters" method - returns Bluetooth adapter state
105
+ /// macOS has a single logical Bluetooth adapter
106
+ /// Waits briefly for CoreBluetooth to determine state if currently unknown
107
+ func handleAdapters(_ request: Request) {
108
+ // Wait for state to be determined (not unknown/resetting)
109
+ bleManager.waitForStateKnown(timeout: 2) { _ in
110
+ let state = bleManager.getState()
111
+ let powered = state == "powered_on"
112
+
113
+ let adapter: [String: Any] = [
114
+ "name": "default",
115
+ "powered": powered,
116
+ "state": state
117
+ ]
118
+
119
+ writeResponse(Response.success(
120
+ id: request.id,
121
+ result: ["adapters": AnyCodable([adapter])]
122
+ ))
123
+ }
124
+ }
125
+
126
+ /// Handle the "scan_start" method - begins BLE scanning
127
+ /// Optional params:
128
+ /// - service_uuids: Array of service UUID strings to filter by
129
+ /// - allow_duplicates: Boolean to receive repeated advertisements
130
+ func handleScanStart(_ request: Request) {
131
+ // Extract optional parameters
132
+ let serviceUUIDs = request.params?["service_uuids"]?.value as? [String]
133
+ let allowDuplicates = request.params?["allow_duplicates"]?.value as? Bool ?? false
134
+
135
+ // Wait for Bluetooth to be powered on (CoreBluetooth needs time to initialize)
136
+ bleManager.waitForPoweredOn(timeout: 5) { ready in
137
+ if ready {
138
+ do {
139
+ try bleManager.startScan(serviceUUIDs: serviceUUIDs, allowDuplicates: allowDuplicates)
140
+ writeResponse(Response.success(
141
+ id: request.id,
142
+ result: ["status": AnyCodable("started")]
143
+ ))
144
+ } catch BLEError.notPoweredOn {
145
+ writeResponse(Response.error(
146
+ id: request.id,
147
+ code: BLEErrorCode.notPoweredOn,
148
+ message: "Bluetooth not powered on",
149
+ data: ["platform_error": "CBManagerState.poweredOff"]
150
+ ))
151
+ } catch {
152
+ writeResponse(Response.error(
153
+ id: request.id,
154
+ code: ErrorCode.internalError,
155
+ message: error.localizedDescription
156
+ ))
157
+ }
158
+ } else {
159
+ writeResponse(Response.error(
160
+ id: request.id,
161
+ code: BLEErrorCode.notPoweredOn,
162
+ message: "Bluetooth not powered on",
163
+ data: ["platform_error": "CBManagerState.poweredOff"]
164
+ ))
165
+ }
166
+ }
167
+ }
168
+
169
+ /// Handle the "scan_stop" method - stops BLE scanning
170
+ func handleScanStop(_ request: Request) -> Response {
171
+ bleManager.stopScan()
172
+ return Response.success(
173
+ id: request.id,
174
+ result: ["status": AnyCodable("stopped")]
175
+ )
176
+ }
177
+
178
+ // MARK: - Async Request Handlers (Connection and Service Discovery)
179
+
180
+ /// Handle the "connect" method - connects to a peripheral
181
+ /// Required params:
182
+ /// - uuid: The peripheral's UUID string (must have been discovered first)
183
+ /// Optional params:
184
+ /// - timeout: Connection timeout in seconds (default: 30)
185
+ func handleConnect(_ request: Request) {
186
+ guard let uuid = request.params?["uuid"]?.value as? String else {
187
+ writeResponse(Response.error(
188
+ id: request.id,
189
+ code: ErrorCode.invalidParams,
190
+ message: "Missing required param: uuid"
191
+ ))
192
+ return
193
+ }
194
+
195
+ let timeout = request.params?["timeout"]?.value as? Int ?? 30
196
+
197
+ // Track timeout
198
+ var timedOut = false
199
+ let timeoutWorkItem = DispatchWorkItem {
200
+ timedOut = true
201
+ writeResponse(Response.error(
202
+ id: request.id,
203
+ code: BLEErrorCode.timeout,
204
+ message: "Connection timeout"
205
+ ))
206
+ }
207
+ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: timeoutWorkItem)
208
+
209
+ bleManager.connect(uuid: uuid) { result in
210
+ // Cancel timeout if we got a result
211
+ timeoutWorkItem.cancel()
212
+ guard !timedOut else { return }
213
+
214
+ switch result {
215
+ case .success:
216
+ writeResponse(Response.success(
217
+ id: request.id,
218
+ result: ["status": AnyCodable("connected")]
219
+ ))
220
+ case .failure(let error):
221
+ let code: Int
222
+ if let bleError = error as? BLEError {
223
+ switch bleError {
224
+ case .deviceNotFound: code = BLEErrorCode.deviceNotFound
225
+ case .notPoweredOn: code = BLEErrorCode.notPoweredOn
226
+ case .connectionFailed: code = BLEErrorCode.connectionFailed
227
+ default: code = BLEErrorCode.operationFailed
228
+ }
229
+ } else {
230
+ code = BLEErrorCode.operationFailed
231
+ }
232
+ writeResponse(Response.error(
233
+ id: request.id,
234
+ code: code,
235
+ message: error.localizedDescription
236
+ ))
237
+ }
238
+ }
239
+ }
240
+
241
+ /// Handle the "disconnect" method - disconnects from a peripheral
242
+ /// Required params:
243
+ /// - uuid: The peripheral's UUID string
244
+ func handleDisconnect(_ request: Request) {
245
+ guard let uuid = request.params?["uuid"]?.value as? String else {
246
+ writeResponse(Response.error(
247
+ id: request.id,
248
+ code: ErrorCode.invalidParams,
249
+ message: "Missing required param: uuid"
250
+ ))
251
+ return
252
+ }
253
+
254
+ // Disconnect with a short timeout (5 seconds)
255
+ var timedOut = false
256
+ let timeoutWorkItem = DispatchWorkItem {
257
+ timedOut = true
258
+ // Even if timeout, respond with disconnected since we initiated the disconnect
259
+ writeResponse(Response.success(
260
+ id: request.id,
261
+ result: ["status": AnyCodable("disconnected")]
262
+ ))
263
+ }
264
+ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5), execute: timeoutWorkItem)
265
+
266
+ bleManager.disconnect(uuid: uuid) {
267
+ timeoutWorkItem.cancel()
268
+ guard !timedOut else { return }
269
+
270
+ writeResponse(Response.success(
271
+ id: request.id,
272
+ result: ["status": AnyCodable("disconnected")]
273
+ ))
274
+ }
275
+ }
276
+
277
+ /// Handle the "discover_services" method - discovers services and characteristics
278
+ /// Required params:
279
+ /// - uuid: The peripheral's UUID string (must be connected)
280
+ /// Optional params:
281
+ /// - timeout: Discovery timeout in seconds (default: 30)
282
+ func handleDiscoverServices(_ request: Request) {
283
+ guard let uuid = request.params?["uuid"]?.value as? String else {
284
+ writeResponse(Response.error(
285
+ id: request.id,
286
+ code: ErrorCode.invalidParams,
287
+ message: "Missing required param: uuid"
288
+ ))
289
+ return
290
+ }
291
+
292
+ let timeout = request.params?["timeout"]?.value as? Int ?? 30
293
+
294
+ // Track timeout
295
+ var timedOut = false
296
+ let timeoutWorkItem = DispatchWorkItem {
297
+ timedOut = true
298
+ writeResponse(Response.error(
299
+ id: request.id,
300
+ code: BLEErrorCode.timeout,
301
+ message: "Service discovery timeout"
302
+ ))
303
+ }
304
+ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: timeoutWorkItem)
305
+
306
+ bleManager.discoverServices(uuid: uuid) { result in
307
+ timeoutWorkItem.cancel()
308
+ guard !timedOut else { return }
309
+
310
+ switch result {
311
+ case .success:
312
+ if let services = bleManager.getServices(uuid: uuid) {
313
+ writeResponse(Response.success(
314
+ id: request.id,
315
+ result: ["services": AnyCodable(services)]
316
+ ))
317
+ } else {
318
+ writeResponse(Response.error(
319
+ id: request.id,
320
+ code: BLEErrorCode.serviceDiscoveryFailed,
321
+ message: "No services found"
322
+ ))
323
+ }
324
+ case .failure(let error):
325
+ let code: Int
326
+ if let bleError = error as? BLEError {
327
+ switch bleError {
328
+ case .notConnected: code = BLEErrorCode.notConnected
329
+ case .serviceDiscoveryFailed: code = BLEErrorCode.serviceDiscoveryFailed
330
+ default: code = BLEErrorCode.operationFailed
331
+ }
332
+ } else {
333
+ code = BLEErrorCode.operationFailed
334
+ }
335
+ writeResponse(Response.error(
336
+ id: request.id,
337
+ code: code,
338
+ message: error.localizedDescription
339
+ ))
340
+ }
341
+ }
342
+ }
343
+
344
+ // MARK: - GATT Operation Handlers
345
+
346
+ /// Handle the "read_characteristic" method - reads a characteristic value
347
+ /// Required params:
348
+ /// - device_uuid: The peripheral's UUID string
349
+ /// - service_uuid: The service UUID (short or full)
350
+ /// - char_uuid: The characteristic UUID (short or full)
351
+ /// Optional params:
352
+ /// - timeout: Read timeout in seconds (default: 30)
353
+ func handleReadCharacteristic(_ request: Request) {
354
+ guard let deviceUUID = request.params?["device_uuid"]?.value as? String,
355
+ let serviceUUID = request.params?["service_uuid"]?.value as? String,
356
+ let charUUID = request.params?["char_uuid"]?.value as? String else {
357
+ writeResponse(Response.error(
358
+ id: request.id,
359
+ code: ErrorCode.invalidParams,
360
+ message: "Missing required params: device_uuid, service_uuid, char_uuid"
361
+ ))
362
+ return
363
+ }
364
+
365
+ let timeout = request.params?["timeout"]?.value as? Int ?? 30
366
+
367
+ // Track timeout
368
+ var timedOut = false
369
+ let timeoutWorkItem = DispatchWorkItem {
370
+ timedOut = true
371
+ writeResponse(Response.error(
372
+ id: request.id,
373
+ code: BLEErrorCode.timeout,
374
+ message: "Read timeout"
375
+ ))
376
+ }
377
+ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: timeoutWorkItem)
378
+
379
+ bleManager.readCharacteristic(
380
+ deviceUUID: deviceUUID,
381
+ serviceUUID: serviceUUID,
382
+ charUUID: charUUID
383
+ ) { result in
384
+ // Cancel timeout if we got a result
385
+ timeoutWorkItem.cancel()
386
+ guard !timedOut else { return }
387
+
388
+ switch result {
389
+ case .success(let data):
390
+ writeResponse(Response.success(
391
+ id: request.id,
392
+ result: ["value": AnyCodable(Array(data).map { Int($0) })]
393
+ ))
394
+ case .failure(let error):
395
+ let code: Int
396
+ if let bleError = error as? BLEError {
397
+ switch bleError {
398
+ case .notConnected: code = BLEErrorCode.notConnected
399
+ case .characteristicNotFound: code = BLEErrorCode.characteristicNotFound
400
+ default: code = BLEErrorCode.operationFailed
401
+ }
402
+ } else {
403
+ code = BLEErrorCode.operationFailed
404
+ }
405
+ writeResponse(Response.error(
406
+ id: request.id,
407
+ code: code,
408
+ message: error.localizedDescription
409
+ ))
410
+ }
411
+ }
412
+ }
413
+
414
+ /// Handle the "write_characteristic" method - writes a value to a characteristic
415
+ /// Required params:
416
+ /// - device_uuid: The peripheral's UUID string
417
+ /// - service_uuid: The service UUID (short or full)
418
+ /// - char_uuid: The characteristic UUID (short or full)
419
+ /// - value: Array of bytes to write
420
+ /// Optional params:
421
+ /// - response: Whether to request write confirmation (default: true)
422
+ /// - timeout: Write timeout in seconds (default: 30)
423
+ func handleWriteCharacteristic(_ request: Request) {
424
+ guard let deviceUUID = request.params?["device_uuid"]?.value as? String,
425
+ let serviceUUID = request.params?["service_uuid"]?.value as? String,
426
+ let charUUID = request.params?["char_uuid"]?.value as? String,
427
+ let valueArray = request.params?["value"]?.value as? [Int] else {
428
+ writeResponse(Response.error(
429
+ id: request.id,
430
+ code: ErrorCode.invalidParams,
431
+ message: "Missing required params: device_uuid, service_uuid, char_uuid, value"
432
+ ))
433
+ return
434
+ }
435
+
436
+ let withResponse = request.params?["response"]?.value as? Bool ?? true
437
+ let data = Data(valueArray.map { UInt8(clamping: $0) })
438
+ let timeout = request.params?["timeout"]?.value as? Int ?? 30
439
+
440
+ // Track timeout
441
+ var timedOut = false
442
+ let timeoutWorkItem = DispatchWorkItem {
443
+ timedOut = true
444
+ writeResponse(Response.error(
445
+ id: request.id,
446
+ code: BLEErrorCode.timeout,
447
+ message: "Write timeout"
448
+ ))
449
+ }
450
+ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: timeoutWorkItem)
451
+
452
+ bleManager.writeCharacteristic(
453
+ deviceUUID: deviceUUID,
454
+ serviceUUID: serviceUUID,
455
+ charUUID: charUUID,
456
+ data: data,
457
+ withResponse: withResponse
458
+ ) { result in
459
+ // Cancel timeout if we got a result
460
+ timeoutWorkItem.cancel()
461
+ guard !timedOut else { return }
462
+
463
+ switch result {
464
+ case .success:
465
+ writeResponse(Response.success(
466
+ id: request.id,
467
+ result: ["status": AnyCodable("written")]
468
+ ))
469
+ case .failure(let error):
470
+ let code: Int
471
+ if let bleError = error as? BLEError {
472
+ switch bleError {
473
+ case .notConnected: code = BLEErrorCode.notConnected
474
+ case .characteristicNotFound: code = BLEErrorCode.characteristicNotFound
475
+ default: code = BLEErrorCode.operationFailed
476
+ }
477
+ } else {
478
+ code = BLEErrorCode.operationFailed
479
+ }
480
+ writeResponse(Response.error(
481
+ id: request.id,
482
+ code: code,
483
+ message: error.localizedDescription
484
+ ))
485
+ }
486
+ }
487
+ }
488
+
489
+ /// Handle the "subscribe" method - subscribes to characteristic notifications
490
+ /// Required params:
491
+ /// - device_uuid: The peripheral's UUID string
492
+ /// - service_uuid: The service UUID (short or full)
493
+ /// - char_uuid: The characteristic UUID (short or full)
494
+ /// Optional params:
495
+ /// - timeout: Subscribe timeout in seconds (default: 30)
496
+ func handleSubscribe(_ request: Request) {
497
+ guard let deviceUUID = request.params?["device_uuid"]?.value as? String,
498
+ let serviceUUID = request.params?["service_uuid"]?.value as? String,
499
+ let charUUID = request.params?["char_uuid"]?.value as? String else {
500
+ writeResponse(Response.error(
501
+ id: request.id,
502
+ code: ErrorCode.invalidParams,
503
+ message: "Missing required params: device_uuid, service_uuid, char_uuid"
504
+ ))
505
+ return
506
+ }
507
+
508
+ let timeout = request.params?["timeout"]?.value as? Int ?? 30
509
+
510
+ // Track timeout
511
+ var timedOut = false
512
+ let timeoutWorkItem = DispatchWorkItem {
513
+ timedOut = true
514
+ writeResponse(Response.error(
515
+ id: request.id,
516
+ code: BLEErrorCode.timeout,
517
+ message: "Subscribe timeout"
518
+ ))
519
+ }
520
+ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: timeoutWorkItem)
521
+
522
+ bleManager.subscribe(
523
+ deviceUUID: deviceUUID,
524
+ serviceUUID: serviceUUID,
525
+ charUUID: charUUID
526
+ ) { result in
527
+ // Cancel timeout if we got a result
528
+ timeoutWorkItem.cancel()
529
+ guard !timedOut else { return }
530
+
531
+ switch result {
532
+ case .success:
533
+ writeResponse(Response.success(
534
+ id: request.id,
535
+ result: ["status": AnyCodable("subscribed")]
536
+ ))
537
+ case .failure(let error):
538
+ let code: Int
539
+ if let bleError = error as? BLEError {
540
+ switch bleError {
541
+ case .notConnected: code = BLEErrorCode.notConnected
542
+ case .characteristicNotFound: code = BLEErrorCode.characteristicNotFound
543
+ default: code = BLEErrorCode.operationFailed
544
+ }
545
+ } else {
546
+ code = BLEErrorCode.operationFailed
547
+ }
548
+ writeResponse(Response.error(
549
+ id: request.id,
550
+ code: code,
551
+ message: error.localizedDescription
552
+ ))
553
+ }
554
+ }
555
+ }
556
+
557
+ /// Handle the "unsubscribe" method - unsubscribes from characteristic notifications
558
+ /// Required params:
559
+ /// - device_uuid: The peripheral's UUID string
560
+ /// - service_uuid: The service UUID (short or full)
561
+ /// - char_uuid: The characteristic UUID (short or full)
562
+ func handleUnsubscribe(_ request: Request) -> Response {
563
+ guard let deviceUUID = request.params?["device_uuid"]?.value as? String,
564
+ let serviceUUID = request.params?["service_uuid"]?.value as? String,
565
+ let charUUID = request.params?["char_uuid"]?.value as? String else {
566
+ return Response.error(
567
+ id: request.id,
568
+ code: ErrorCode.invalidParams,
569
+ message: "Missing required params: device_uuid, service_uuid, char_uuid"
570
+ )
571
+ }
572
+
573
+ bleManager.unsubscribe(
574
+ deviceUUID: deviceUUID,
575
+ serviceUUID: serviceUUID,
576
+ charUUID: charUUID
577
+ )
578
+
579
+ return Response.success(
580
+ id: request.id,
581
+ result: ["status": AnyCodable("unsubscribed")]
582
+ )
583
+ }
584
+
585
+ // MARK: - Main Entry Point
586
+
587
+ func main() {
588
+ // Disable stdout buffering for immediate output
589
+ setbuf(stdout, nil)
590
+
591
+ // Setup event handler to write events to stdout
592
+ // Events are dispatched to main queue for thread safety
593
+ bleManager.onEvent = { event in
594
+ DispatchQueue.main.async {
595
+ writeEvent(event)
596
+ }
597
+ }
598
+
599
+ let decoder = JSONDecoder()
600
+
601
+ // Read stdin on a background queue so the main RunLoop can process CoreBluetooth callbacks
602
+ DispatchQueue.global(qos: .userInitiated).async {
603
+ while let line = readLine() {
604
+ // Skip empty lines
605
+ guard !line.isEmpty else { continue }
606
+
607
+ do {
608
+ // Parse JSON request
609
+ guard let jsonData = line.data(using: .utf8) else {
610
+ DispatchQueue.main.async {
611
+ writeResponse(createParseErrorResponse())
612
+ }
613
+ continue
614
+ }
615
+
616
+ let request = try decoder.decode(Request.self, from: jsonData)
617
+
618
+ // Handle request on main queue for thread safety with CoreBluetooth
619
+ DispatchQueue.main.async {
620
+ if let response = handleRequest(request) {
621
+ // Sync response - write immediately
622
+ writeResponse(response)
623
+ }
624
+ // Async responses (nil) are written by their handlers
625
+ }
626
+
627
+ } catch {
628
+ // JSON parse error or decoding error
629
+ DispatchQueue.main.async {
630
+ writeResponse(createParseErrorResponse())
631
+ }
632
+ }
633
+ }
634
+
635
+ // stdin closed (EOF) - exit cleanly
636
+ exit(0)
637
+ }
638
+
639
+ // Run main RunLoop for CoreBluetooth callbacks
640
+ // This keeps the process alive and allows CBCentralManagerDelegate methods to be called
641
+ RunLoop.main.run()
642
+ }
643
+
644
+ // Run the main loop
645
+ main()