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,653 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+
6
+ module RBLE
7
+ module Backend
8
+ # CoreBluetooth backend for macOS BLE operations via subprocess
9
+ class CoreBluetooth < Base
10
+ HELPER_PATH = File.expand_path('../../../ext/macos_ble/.build/release/RBLEHelper', __dir__)
11
+
12
+ def initialize
13
+ @stdin = nil
14
+ @stdout = nil
15
+ @stderr = nil
16
+ @wait_thread = nil
17
+ @request_id = 0
18
+ @mutex = Mutex.new
19
+ @scanning = false
20
+ @scan_callback = nil
21
+ @reader_thread = nil
22
+ @event_queue = Queue.new
23
+
24
+ # Separate queue for responses (id present) vs async events (method present)
25
+ @response_queue = Queue.new
26
+
27
+ # Connection tracking
28
+ @connected_devices = {} # device_uuid => true
29
+ @device_services = {} # device_uuid => [service_data, ...]
30
+ @connection_objects = {} # device_uuid => Connection instance
31
+
32
+ # Subscription tracking
33
+ @subscriptions = {} # char_identifier => callback
34
+
35
+ # Background event processor for async events (disconnect, notifications)
36
+ @event_processor_thread = nil
37
+ @event_processor_running = false
38
+
39
+ # Thread safety: protects shared state accessed from multiple threads
40
+ # (event_processor_thread and user thread)
41
+ @state_mutex = Mutex.new
42
+
43
+ # Best-effort cleanup on process exit
44
+ at_exit { cleanup_all_connections }
45
+ end
46
+
47
+ # Start the subprocess if not running
48
+ def ensure_subprocess
49
+ return if @wait_thread&.alive?
50
+
51
+ unless File.exist?(HELPER_PATH)
52
+ raise SubprocessError, <<~MSG.strip
53
+ macOS BLE helper not found at #{HELPER_PATH}
54
+
55
+ The helper should build automatically during gem install. To build manually:
56
+ cd #{File.dirname(HELPER_PATH).sub('/.build/release', '')}
57
+ swift build -c release
58
+
59
+ Ensure Xcode Command Line Tools are installed:
60
+ xcode-select --install
61
+ MSG
62
+ end
63
+
64
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(HELPER_PATH)
65
+
66
+ # Start reader thread for async events
67
+ start_reader_thread
68
+
69
+ # Start background event processor
70
+ start_event_processor
71
+ end
72
+
73
+ # Send request and wait for response
74
+ def send_request(method, params = nil, timeout: 30)
75
+ ensure_subprocess
76
+ raise SubprocessError, 'Subprocess not running' unless @wait_thread&.alive?
77
+
78
+ request_id = nil
79
+ @mutex.synchronize do
80
+ @request_id += 1
81
+ request_id = @request_id
82
+ request = { id: request_id, method: method }
83
+ request[:params] = params if params
84
+
85
+ @stdin.puts(JSON.generate(request))
86
+ @stdin.flush
87
+ end
88
+
89
+ # Wait for response in response queue (async events handled by event_processor_thread)
90
+ deadline = Time.now + timeout
91
+ loop do
92
+ remaining = deadline - Time.now
93
+ raise ConnectionTimeoutError, timeout if remaining <= 0
94
+
95
+ response = @response_queue.pop(timeout: remaining)
96
+ raise ConnectionTimeoutError, timeout if response.nil? # timeout expired
97
+
98
+ if response[:id] == request_id
99
+ handle_response_error(response) if response[:error]
100
+ return response[:result]
101
+ else
102
+ # Not our response, put it back (shouldn't happen often with single-threaded requests)
103
+ @response_queue.push(response)
104
+ end
105
+ end
106
+ end
107
+
108
+ def shutdown
109
+ @event_processor_running = false
110
+ @event_processor_thread&.kill
111
+ @reader_thread&.kill
112
+ @stdin&.close
113
+ @stdout&.close
114
+ @stderr&.close
115
+ @wait_thread&.kill
116
+ end
117
+
118
+ # Backend::Base implementations
119
+
120
+ def start_scan(service_uuids: nil, allow_duplicates: false, adapter: nil, active: true, &block)
121
+ @state_mutex.synchronize do
122
+ raise ScanInProgressError if @scanning
123
+ @scanning = true
124
+ end
125
+ raise ArgumentError, 'Block required for scan callback' unless block_given?
126
+
127
+ unless active
128
+ RBLE.rble_warn "passive scanning (active: false) is not explicitly supported on macOS. " \
129
+ "CoreBluetooth does not expose active/passive scan control. " \
130
+ "Scanning will proceed normally."
131
+ end
132
+
133
+ if adapter
134
+ RBLE.rble_warn "macOS does not support adapter selection. The default adapter will be used."
135
+ end
136
+
137
+ @scan_callback = block
138
+
139
+ params = { allow_duplicates: allow_duplicates }
140
+ params[:service_uuids] = service_uuids if service_uuids
141
+
142
+ begin
143
+ send_request('scan_start', params)
144
+ rescue StandardError
145
+ @state_mutex.synchronize do
146
+ @scanning = false
147
+ @scan_callback = nil
148
+ end
149
+ raise
150
+ end
151
+ end
152
+
153
+ def stop_scan
154
+ @state_mutex.synchronize { return unless @scanning }
155
+
156
+ begin
157
+ send_request('scan_stop')
158
+ ensure
159
+ @state_mutex.synchronize do
160
+ @scanning = false
161
+ @scan_callback = nil
162
+ end
163
+ end
164
+ end
165
+
166
+ def scanning?
167
+ @state_mutex.synchronize { @scanning }
168
+ end
169
+
170
+ def adapters
171
+ result = send_request('adapters')
172
+ result['adapters'].map do |a|
173
+ {
174
+ name: a['name'],
175
+ address: nil, # macOS doesn't expose adapter MAC
176
+ powered: a['powered']
177
+ }
178
+ end
179
+ end
180
+
181
+ def process_events(timeout: nil)
182
+ deadline = timeout ? Time.now + timeout : nil
183
+
184
+ loop do
185
+ remaining = deadline ? [deadline - Time.now, 0].max : 0.1
186
+ break if deadline && remaining <= 0
187
+
188
+ begin
189
+ event = @event_queue.pop(true)
190
+ handle_async_event(event)
191
+ rescue ThreadError
192
+ sleep [remaining, 0.1].min
193
+ end
194
+
195
+ break if deadline && Time.now >= deadline
196
+ end
197
+
198
+ false # Not a clean shutdown
199
+ end
200
+
201
+ # Connect to a BLE device
202
+ # @param device_identifier [String] Device UUID (from scanning)
203
+ # @param timeout [Numeric] Connection timeout in seconds
204
+ # @return [Boolean] true on successful connection
205
+ # @raise [AlreadyConnectedError] if already connected
206
+ # @raise [ConnectionTimeoutError] if connection times out
207
+ def connect_device(device_identifier, timeout: 30)
208
+ # Check if already connected (thread-safe)
209
+ @state_mutex.synchronize do
210
+ raise AlreadyConnectedError if @connected_devices.key?(device_identifier)
211
+ end
212
+
213
+ send_request('connect', {
214
+ uuid: device_identifier,
215
+ timeout: timeout
216
+ }, timeout: timeout + 5) # Extra buffer for subprocess
217
+
218
+ @state_mutex.synchronize { @connected_devices[device_identifier] = true }
219
+ true
220
+ end
221
+
222
+ # Disconnect from a BLE device
223
+ # @param device_identifier [String] Device UUID
224
+ # @return [void]
225
+ def disconnect_device(device_identifier)
226
+ @state_mutex.synchronize do
227
+ @connected_devices.delete(device_identifier)
228
+ @device_services.delete(device_identifier)
229
+ @subscriptions.delete_if { |char_id, _| char_id.start_with?(device_identifier) }
230
+ end
231
+
232
+ begin
233
+ send_request('disconnect', { uuid: device_identifier }, timeout: 5)
234
+ rescue StandardError
235
+ # Ignore errors during cleanup
236
+ end
237
+ end
238
+
239
+ # Discover GATT services on a connected device
240
+ # @param device_identifier [String] Device UUID
241
+ # @param timeout [Numeric] Discovery timeout in seconds
242
+ # @return [Array<Hash>] Service data with characteristics
243
+ # @raise [NotConnectedError] if not connected
244
+ # @raise [ServiceDiscoveryError] if discovery fails
245
+ def discover_services(device_identifier, connection: nil, timeout: 30)
246
+ connected, cached_services = @state_mutex.synchronize do
247
+ [@connected_devices.key?(device_identifier), @device_services[device_identifier]]
248
+ end
249
+ raise NotConnectedError unless connected
250
+
251
+ # Return cached services if available
252
+ return cached_services if cached_services
253
+
254
+ result = send_request('discover_services', {
255
+ uuid: device_identifier,
256
+ timeout: timeout
257
+ }, timeout: timeout + 5)
258
+
259
+ services = build_services_from_result(result['services'], device_identifier)
260
+ @state_mutex.synchronize { @device_services[device_identifier] = services }
261
+ services
262
+ end
263
+
264
+ def device_path_for_address(address, adapter: nil)
265
+ # On macOS, address IS the UUID - just return it
266
+ # Validate it looks like a UUID
267
+ unless address =~ /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i
268
+ raise ConnectionError, "Invalid macOS device identifier '#{address}'. " \
269
+ "On macOS, use the UUID from scanning (not MAC address)."
270
+ end
271
+ address
272
+ end
273
+
274
+ # Register a Connection object for disconnect monitoring
275
+ # @param device_identifier [String] Device UUID
276
+ # @param connection [Connection] The Connection instance
277
+ # @return [void]
278
+ def register_connection(device_identifier, connection)
279
+ @state_mutex.synchronize { @connection_objects[device_identifier] = connection }
280
+ end
281
+
282
+ # Unregister a Connection object from disconnect monitoring
283
+ # @param device_identifier [String] Device UUID
284
+ # @return [void]
285
+ def unregister_connection(device_identifier)
286
+ @state_mutex.synchronize { @connection_objects.delete(device_identifier) }
287
+ end
288
+
289
+ # Read a characteristic value
290
+ # @param char_identifier [String] Format: "device_uuid:service_uuid:char_uuid"
291
+ # @param timeout [Numeric] Read timeout in seconds
292
+ # @return [String] Binary string (ASCII-8BIT encoding)
293
+ # @raise [ReadError] if read fails
294
+ def read_characteristic(char_identifier, connection: nil, timeout: 30)
295
+ device_uuid, service_uuid, char_uuid = parse_char_identifier(char_identifier)
296
+
297
+ result = send_request('read_characteristic', {
298
+ device_uuid: device_uuid,
299
+ service_uuid: service_uuid,
300
+ char_uuid: char_uuid,
301
+ timeout: timeout
302
+ }, timeout: timeout + 5)
303
+
304
+ # Convert byte array to binary string
305
+ (result['value'] || []).map(&:to_i).pack('C*')
306
+ rescue RBLE::Error
307
+ raise
308
+ rescue StandardError => e
309
+ raise ReadError, translate_error(e)
310
+ end
311
+
312
+ # Write a value to a characteristic
313
+ # @param char_identifier [String] Format: "device_uuid:service_uuid:char_uuid"
314
+ # @param data [String, Array<Integer>] Data to write
315
+ # @param response [Boolean] Wait for write response
316
+ # @param timeout [Numeric] Write timeout in seconds
317
+ # @return [Boolean] true on success
318
+ # @raise [WriteError] if write fails
319
+ def write_characteristic(char_identifier, data, connection: nil, response: true, timeout: 30)
320
+ device_uuid, service_uuid, char_uuid = parse_char_identifier(char_identifier)
321
+
322
+ # Convert string to bytes array if needed
323
+ bytes = data.is_a?(String) ? data.bytes : data
324
+
325
+ send_request('write_characteristic', {
326
+ device_uuid: device_uuid,
327
+ service_uuid: service_uuid,
328
+ char_uuid: char_uuid,
329
+ value: bytes,
330
+ response: response,
331
+ timeout: timeout
332
+ }, timeout: timeout + 5)
333
+
334
+ true
335
+ rescue RBLE::Error
336
+ raise
337
+ rescue StandardError => e
338
+ raise WriteError, translate_error(e)
339
+ end
340
+
341
+ # Subscribe to characteristic notifications
342
+ # @param char_identifier [String] Format: "device_uuid:service_uuid:char_uuid"
343
+ # @yield [String] Called with value (binary string) on each notification
344
+ # @return [Boolean] true on success
345
+ # @raise [NotifyError] if subscription fails
346
+ def subscribe_characteristic(char_identifier, connection: nil, &callback)
347
+ already_subscribed = @state_mutex.synchronize { @subscriptions.key?(char_identifier) }
348
+ return true if already_subscribed
349
+
350
+ device_uuid, service_uuid, char_uuid = parse_char_identifier(char_identifier)
351
+
352
+ send_request('subscribe', {
353
+ device_uuid: device_uuid,
354
+ service_uuid: service_uuid,
355
+ char_uuid: char_uuid
356
+ }, timeout: 30)
357
+
358
+ @state_mutex.synchronize { @subscriptions[char_identifier] = callback }
359
+ true
360
+ rescue RBLE::Error
361
+ raise
362
+ rescue StandardError => e
363
+ raise NotifyError, translate_error(e)
364
+ end
365
+
366
+ # Unsubscribe from characteristic notifications
367
+ # @param char_identifier [String] Format: "device_uuid:service_uuid:char_uuid"
368
+ # @return [Boolean] true on success
369
+ def unsubscribe_characteristic(char_identifier, connection: nil)
370
+ callback = @state_mutex.synchronize { @subscriptions.delete(char_identifier) }
371
+ return true unless callback
372
+
373
+ device_uuid, service_uuid, char_uuid = parse_char_identifier(char_identifier)
374
+
375
+ begin
376
+ send_request('unsubscribe', {
377
+ device_uuid: device_uuid,
378
+ service_uuid: service_uuid,
379
+ char_uuid: char_uuid
380
+ }, timeout: 5)
381
+ rescue StandardError
382
+ # Ignore errors during cleanup
383
+ end
384
+
385
+ true
386
+ end
387
+
388
+ # Get active subscriptions for a connection
389
+ # @param connection [Connection] Connection to query
390
+ # @return [Array<Hash>] Subscription info hashes with :uuid and :path
391
+ def subscriptions_for_connection(connection)
392
+ device_uuid = connection.address
393
+ @state_mutex.synchronize do
394
+ @subscriptions.select { |path, _| path.start_with?(device_uuid) }
395
+ .map { |path, _callback| { path: path, uuid: path.split(':').last } }
396
+ end
397
+ end
398
+
399
+ private
400
+
401
+ # Best-effort disconnect of all tracked connections during process exit
402
+ # @return [void]
403
+ def cleanup_all_connections
404
+ connections = @state_mutex.synchronize { @connection_objects.values.dup }
405
+ connections.each do |conn|
406
+ conn.disconnect if conn.connected?
407
+ rescue StandardError
408
+ # best-effort cleanup during exit
409
+ end
410
+ shutdown if connections.any?
411
+ end
412
+
413
+ def start_reader_thread
414
+ @reader_thread = Thread.new do
415
+ while (line = @stdout.gets)
416
+ begin
417
+ event = JSON.parse(line, symbolize_names: false)
418
+ # Normalize keys for Ruby
419
+ event = event.transform_keys(&:to_sym) if event.is_a?(Hash)
420
+
421
+ # Route to appropriate queue based on whether it's a response or async event
422
+ if event[:id]
423
+ # Response to a request
424
+ @response_queue.push(event)
425
+ else
426
+ # Async event (device_discovered, notification, disconnected, etc.)
427
+ @event_queue.push(event)
428
+ end
429
+ rescue JSON::ParserError
430
+ # Log to stderr, don't crash
431
+ warn "[RBLE] Invalid JSON from subprocess: #{line}"
432
+ end
433
+ end
434
+ rescue IOError
435
+ # Subprocess closed
436
+ end
437
+ end
438
+
439
+ # Start background thread to process async events
440
+ def start_event_processor
441
+ return if @event_processor_thread&.alive?
442
+
443
+ @event_processor_running = true
444
+ @event_processor_thread = Thread.new do
445
+ while @event_processor_running
446
+ begin
447
+ # Block with timeout so we can check @event_processor_running
448
+ event = @event_queue.pop(true) rescue nil
449
+ handle_async_event(event) if event
450
+ rescue LocalJumpError
451
+ # Expected when user's callback uses `break` to exit early from scanning
452
+ # This is normal behavior, not an error
453
+ rescue StandardError => e
454
+ warn "[RBLE] Event processor error: #{e.message}"
455
+ end
456
+ sleep 0.01 # Small sleep to prevent CPU spinning
457
+ end
458
+ end
459
+ end
460
+
461
+ def handle_async_event(event)
462
+ case event[:method] || event['method']
463
+ when 'device_discovered'
464
+ handle_device_discovered(event[:params] || event['params'])
465
+ when 'notification'
466
+ handle_notification(event[:params] || event['params'])
467
+ when 'disconnected'
468
+ handle_disconnected(event[:params] || event['params'])
469
+ when 'state_changed'
470
+ # Could notify app of BT state change
471
+ when 'connected'
472
+ # Connection state events (handled via request/response)
473
+ end
474
+ end
475
+
476
+ def handle_device_discovered(params)
477
+ return unless @scan_callback
478
+
479
+ # Build Device from params
480
+ device = Device.new(
481
+ address: params['uuid'], # On macOS, UUID is the address
482
+ name: params['name'],
483
+ rssi: params['rssi'],
484
+ manufacturer_data: parse_manufacturer_data(params['manufacturer_data']),
485
+ manufacturer_data_raw: parse_manufacturer_data_raw(params['manufacturer_data']),
486
+ service_data: parse_service_data(params['service_data']),
487
+ service_uuids: params['service_uuids'] || [],
488
+ tx_power: params['tx_power'],
489
+ address_type: nil # CoreBluetooth doesn't expose address type
490
+ )
491
+
492
+ @scan_callback.call(device)
493
+ end
494
+
495
+ def parse_manufacturer_data(data)
496
+ return {} unless data.is_a?(Hash)
497
+
498
+ company_id = data['company_id']
499
+ bytes = data['data']
500
+ return {} unless company_id && bytes
501
+
502
+ { company_id => bytes }
503
+ end
504
+
505
+ def parse_manufacturer_data_raw(data)
506
+ return {} unless data.is_a?(Hash)
507
+
508
+ company_id = data['company_id']
509
+ bytes = data['data']
510
+ return {} unless company_id && bytes
511
+
512
+ { company_id => bytes.pack('C*') }
513
+ end
514
+
515
+ def parse_service_data(data)
516
+ return {} unless data.is_a?(Hash)
517
+
518
+ data.transform_values { |bytes| bytes.is_a?(Array) ? bytes : [] }
519
+ end
520
+
521
+ # Build Service hashes from subprocess discover_services result
522
+ # @param raw_services [Array] Raw service data from subprocess
523
+ # @param device_identifier [String] Device UUID
524
+ # @return [Array<Hash>] Services with characteristics and paths
525
+ def build_services_from_result(raw_services, device_identifier)
526
+ (raw_services || []).map do |service_data|
527
+ characteristics = (service_data['characteristics'] || []).map do |char_data|
528
+ {
529
+ data: Characteristic.new(
530
+ uuid: char_data['uuid'],
531
+ flags: char_data['properties'] || [],
532
+ service_uuid: service_data['uuid']
533
+ ),
534
+ path: "#{device_identifier}:#{service_data['uuid']}:#{char_data['uuid']}"
535
+ }
536
+ end
537
+
538
+ {
539
+ uuid: service_data['uuid'],
540
+ primary: service_data['primary'] != false,
541
+ characteristics: characteristics
542
+ }
543
+ end
544
+ end
545
+
546
+ def handle_response_error(response)
547
+ error = response[:error]
548
+ message = error['message'] || error[:message]
549
+ platform_error = (error['data'] || error[:data])&.dig('platform_error')
550
+ full_message = platform_error ? "#{message} (#{platform_error})" : message
551
+
552
+ case message
553
+ when /not powered on/i
554
+ raise BluetoothOffError
555
+ when /unauthorized/i
556
+ raise PermissionError, 'access Bluetooth on macOS'
557
+ when /not connected/i
558
+ raise NotConnectedError, full_message
559
+ when /device not found/i, /no peripheral/i, /unknown device/i
560
+ raise DeviceNotFoundError.new(full_message)
561
+ when /connection.*(failed|refused)/i
562
+ raise ConnectionFailed.new(nil, full_message)
563
+ when /connection.*timeout/i, /timed? ?out/i
564
+ raise ConnectionTimeoutError
565
+ when /service.*discover/i, /discover.*(?:fail|error)/i, /fail.*discover/i
566
+ raise ServiceDiscoveryError, full_message
567
+ when /characteristic not found/i
568
+ raise CharacteristicNotFoundError
569
+ else
570
+ raise Error, full_message
571
+ end
572
+ end
573
+
574
+ # Handle notification event from subprocess
575
+ # @param params [Hash] Notification parameters from subprocess
576
+ def handle_notification(params)
577
+ return unless params
578
+
579
+ device_uuid = params['device_uuid']
580
+ service_uuid = params['service_uuid']
581
+ char_uuid = params['char_uuid']
582
+ value = params['value']
583
+
584
+ identifier = "#{device_uuid}:#{service_uuid}:#{char_uuid}"
585
+ callback = @state_mutex.synchronize { @subscriptions[identifier] }
586
+ return unless callback
587
+
588
+ # Convert byte array to binary string
589
+ binary_value = (value || []).map(&:to_i).pack('C*')
590
+ callback.call(binary_value)
591
+ end
592
+
593
+ # Handle disconnect event from subprocess
594
+ # @param params [Hash] Disconnect parameters from subprocess
595
+ def handle_disconnected(params)
596
+ return unless params
597
+
598
+ uuid = params['uuid']
599
+ reason_str = params['reason'] || 'unknown'
600
+
601
+ # Map string reason to symbol
602
+ reason = case reason_str
603
+ when 'user_requested' then :user_requested
604
+ when 'timeout' then :timeout
605
+ when 'remote_disconnect' then :remote_disconnect
606
+ when 'connection_failed' then :connection_failed
607
+ when 'link_loss' then :link_loss
608
+ else :unknown
609
+ end
610
+
611
+ # Clean up tracking and get connection object atomically (thread-safe)
612
+ connection = @state_mutex.synchronize do
613
+ @connected_devices.delete(uuid)
614
+ @device_services.delete(uuid)
615
+ @subscriptions.delete_if { |char_id, _| char_id.start_with?(uuid) }
616
+ @connection_objects.delete(uuid)
617
+ end
618
+
619
+ # Notify Connection object OUTSIDE mutex to prevent deadlock
620
+ connection&.handle_disconnect(reason)
621
+ end
622
+
623
+ # Parse characteristic identifier into components
624
+ # @param identifier [String] Format: "device_uuid:service_uuid:char_uuid"
625
+ # @return [Array<String>] [device_uuid, service_uuid, char_uuid]
626
+ # @raise [ArgumentError] if identifier is invalid
627
+ def parse_char_identifier(identifier)
628
+ parts = identifier.split(':')
629
+ raise ArgumentError, "Invalid characteristic identifier: #{identifier}" unless parts.length == 3
630
+
631
+ parts
632
+ end
633
+
634
+ # Translate non-RBLE errors: raise specific RBLE errors for known
635
+ # patterns, return message string for unknown errors (wrapped by caller).
636
+ # @param error [StandardError] The error to translate
637
+ # @return [String] Translated error message (for unknown patterns)
638
+ # @raise [NotConnectedError, CharacteristicNotFoundError, ConnectionTimeoutError]
639
+ def translate_error(error)
640
+ case error.message
641
+ when /not connected/i
642
+ raise NotConnectedError, 'Device not connected'
643
+ when /characteristic not found/i
644
+ raise CharacteristicNotFoundError
645
+ when /timeout/i
646
+ raise ConnectionTimeoutError
647
+ else
648
+ error.message
649
+ end
650
+ end
651
+ end
652
+ end
653
+ end