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,1279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../bluez'
4
+
5
+ module RBLE
6
+ module Backend
7
+ # BlueZ D-Bus backend for Linux BLE operations
8
+ class BlueZ < Base
9
+ def initialize
10
+ # Scan session (temporary D-Bus session for scanning only)
11
+ @scan_session = nil
12
+ @scan_adapter_path = nil # Store adapter path for filtering
13
+ @scanning = false
14
+ @scan_callback = nil
15
+ @known_devices = {} # path => Device
16
+ @allow_duplicates = false
17
+ @signal_handlers = []
18
+
19
+ # Connection tracking
20
+ @connected_devices = {} # device_path => BlueZ::Device
21
+ @device_sessions = {} # device_path => DBusSession (for async operations)
22
+ @device_services = {} # device_path => [Service, ...]
23
+
24
+ # Subscription tracking for notifications
25
+ @subscriptions = {} # char_path => { callback:, wrapper:, connection: }
26
+
27
+ # Connection object tracking for disconnect notifications
28
+ @connection_objects = {} # device_path => Connection instance
29
+
30
+ # Thread safety: protects shared state accessed from multiple threads
31
+ # (D-Bus signal handlers run on rble-dbus-loop thread, user code on main thread)
32
+ @state_mutex = Mutex.new
33
+
34
+ # Best-effort cleanup on process exit
35
+ at_exit { cleanup_all_connections }
36
+ end
37
+
38
+ # Start scanning for BLE devices
39
+ # @param service_uuids [Array<String>, nil] Filter by service UUIDs
40
+ # @param allow_duplicates [Boolean] Callback on every advertisement
41
+ # @param adapter [String, nil] Adapter name (e.g., "hci0")
42
+ # @yield [Device] Called when device discovered/updated
43
+ def start_scan(service_uuids: nil, allow_duplicates: false, adapter: nil, active: true, &block)
44
+ @state_mutex.synchronize do
45
+ raise ScanInProgressError if @scanning
46
+ @scanning = true
47
+ end
48
+ raise ArgumentError, 'Block required for scan callback' unless block_given?
49
+
50
+ @scan_callback = block
51
+ @allow_duplicates = allow_duplicates
52
+ # Store normalized service UUIDs for secondary filtering (BlueZ may ignore filter)
53
+ @service_uuid_filter = service_uuids&.map { |uuid| normalize_uuid_for_filter(uuid) }
54
+ @state_mutex.synchronize { @known_devices.clear }
55
+
56
+ begin
57
+ # Create temporary D-Bus session for scanning
58
+ # This session will be destroyed when scan stops
59
+ @scan_session = RBLE::BlueZ::DBusSession.new
60
+ @scan_session.connect
61
+
62
+ # Setup signal handlers BEFORE starting event loop to avoid blocking
63
+ # on_signal does synchronous AddMatch which blocks if event loop is running
64
+ setup_scan_signal_handlers(@scan_session)
65
+
66
+ # Start event loop - async operations need it running to process D-Bus callbacks
67
+ @scan_session.start_event_loop
68
+
69
+ # Setup adapter (uses async introspection which needs event loop)
70
+ @scan_adapter_path = setup_adapter_for_scan(@scan_session, adapter)
71
+ start_discovery_on_session(@scan_session, service_uuids: service_uuids, allow_duplicates: allow_duplicates)
72
+
73
+ # Process existing devices (already discovered before scan started)
74
+ process_existing_devices_from_session(@scan_session)
75
+ rescue StandardError
76
+ stop_scan_cleanup
77
+ raise
78
+ end
79
+ end
80
+
81
+ # Stop current scan
82
+ def stop_scan
83
+ @state_mutex.synchronize { return unless @scanning }
84
+
85
+ begin
86
+ # Stop discovery on the scan session's adapter
87
+ if @scan_session && @scan_adapter_path
88
+ adapter = RBLE::BlueZ::Adapter.new_from_session(@scan_session, @scan_adapter_path)
89
+ adapter.stop_discovery
90
+ end
91
+ rescue StandardError => e
92
+ RBLE.logger&.debug("[RBLE] Error during stop_scan cleanup: #{e.class}: #{e.message}")
93
+ end
94
+
95
+ # Destroy the temporary scan session - this connection is never reused
96
+ stop_scan_cleanup
97
+ end
98
+
99
+ # Check if scanning
100
+ # @return [Boolean]
101
+ def scanning?
102
+ @state_mutex.synchronize { @scanning }
103
+ end
104
+
105
+ # Get the scan session's event queue for signal-safe direct push
106
+ # @return [Thread::Queue, nil]
107
+ def scan_event_queue
108
+ @scan_session&.event_loop_queue
109
+ end
110
+
111
+ # List available adapters
112
+ # @return [Array<Hash>]
113
+ def adapters
114
+ conn = RBLE::BlueZ::DBusConnection.new
115
+ conn.connect
116
+
117
+ om = conn.object_manager
118
+ managed = om.GetManagedObjects.first
119
+
120
+ adapters = managed.select { |_path, ifaces| ifaces.key?(RBLE::BlueZ::ADAPTER_INTERFACE) }
121
+ adapters.map do |path, ifaces|
122
+ props = ifaces[RBLE::BlueZ::ADAPTER_INTERFACE]
123
+ {
124
+ name: path.split('/').last,
125
+ address: props['Address'],
126
+ powered: props['Powered'],
127
+ discoverable: props['Discoverable'],
128
+ pairable: props['Pairable'],
129
+ alias: props['Alias'],
130
+ discovering: props['Discovering']
131
+ }
132
+ end
133
+ rescue StandardError => e
134
+ raise Error, "Failed to list adapters: #{e.message}"
135
+ ensure
136
+ conn&.disconnect
137
+ end
138
+
139
+ # Get the default adapter name
140
+ # @return [String] Adapter name (e.g., "hci0")
141
+ # @raise [AdapterNotFoundError] if no adapter found
142
+ def default_adapter_name
143
+ adapter_list = adapters
144
+ raise AdapterNotFoundError if adapter_list.empty?
145
+
146
+ adapter_list.first[:name]
147
+ end
148
+
149
+ # Get full info for a single adapter
150
+ # @param adapter_name [String] Adapter name (e.g., "hci0")
151
+ # @return [Hash] Adapter properties
152
+ # @raise [AdapterNotFoundError] if adapter not found
153
+ def adapter_info(adapter_name)
154
+ conn = RBLE::BlueZ::DBusConnection.new
155
+ conn.connect
156
+
157
+ om = conn.object_manager
158
+ managed = om.GetManagedObjects.first
159
+
160
+ adapter_path = "/org/bluez/#{adapter_name}"
161
+ entry = managed.find { |path, ifaces| path == adapter_path && ifaces.key?(RBLE::BlueZ::ADAPTER_INTERFACE) }
162
+ raise AdapterNotFoundError unless entry
163
+
164
+ _path, ifaces = entry
165
+ props = ifaces[RBLE::BlueZ::ADAPTER_INTERFACE]
166
+ {
167
+ name: adapter_name,
168
+ address: props['Address'],
169
+ powered: props['Powered'],
170
+ discoverable: props['Discoverable'],
171
+ pairable: props['Pairable'],
172
+ alias: props['Alias'],
173
+ discovering: props['Discovering']
174
+ }
175
+ rescue AdapterNotFoundError
176
+ raise
177
+ rescue StandardError => e
178
+ raise Error, "Failed to get adapter info: #{e.message}"
179
+ ensure
180
+ conn&.disconnect
181
+ end
182
+
183
+ # Set a property on an adapter
184
+ # @param adapter_name [String] Adapter name (e.g., "hci0")
185
+ # @param property [Symbol] Property to set (:powered, :discoverable, :pairable, :alias)
186
+ # @param value [Object] Value to set
187
+ # @return [true]
188
+ # @raise [RBLE::Error] on failure
189
+ def adapter_set_property(adapter_name, property, value)
190
+ session = RBLE::BlueZ::DBusSession.new
191
+ session.connect
192
+ session.start_event_loop
193
+
194
+ begin
195
+ adapter_path = "/org/bluez/#{adapter_name}"
196
+ adapter = RBLE::BlueZ::Adapter.new_from_session(session, adapter_path)
197
+
198
+ case property
199
+ when :powered
200
+ adapter.set_powered(value)
201
+ when :discoverable
202
+ adapter.set_discoverable(value)
203
+ when :pairable
204
+ adapter.set_pairable(value)
205
+ when :alias
206
+ adapter.set_alias(value)
207
+ else
208
+ raise Error, "Unknown adapter property: #{property}"
209
+ end
210
+
211
+ true
212
+ ensure
213
+ session.close
214
+ end
215
+ rescue RBLE::Error
216
+ raise
217
+ rescue StandardError => e
218
+ raise Error, "Failed to set adapter property: #{e.message}"
219
+ end
220
+
221
+ # Process events (blocking) - call this to receive callbacks
222
+ # @param timeout [Numeric, nil] Timeout in seconds
223
+ # @return [Boolean] true if shutdown, false if timeout
224
+ def process_events(timeout: nil)
225
+ return false unless @scanning
226
+ return false unless @scan_session
227
+
228
+ @scan_session.process_events(timeout: timeout) do |event|
229
+ handle_event(event)
230
+ end
231
+ end
232
+
233
+ # Connect to a BLE device
234
+ # Uses a temporary D-Bus connection for the connect operation
235
+ # @param device_path [String] D-Bus device path
236
+ # @param timeout [Numeric] Connection timeout in seconds (default: 30)
237
+ # @return [Boolean] true on successful connection
238
+ # @raise [AlreadyConnectedError] if device is already connected
239
+ # @raise [ConnectionTimeoutError] if connection times out
240
+ # @raise [ConnectionError] on other connection failures
241
+ def connect_device(device_path, timeout: 30)
242
+ # Check if already connected (thread-safe)
243
+ @state_mutex.synchronize do
244
+ raise AlreadyConnectedError if @connected_devices.key?(device_path)
245
+ end
246
+
247
+ # Auto-stop scan before connect - BlueZ doesn't handle scan+connect well
248
+ if scanning?
249
+ RBLE.logger&.warn('[RBLE] Stopping scan before connect (BlueZ limitation)')
250
+ stop_scan
251
+ end
252
+
253
+ # Create DBusSession for async operations
254
+ session = RBLE::BlueZ::DBusSession.new
255
+ session.connect
256
+ session.start_event_loop
257
+
258
+ begin
259
+ # Use async_connect which handles everything without deadlock
260
+ session.async_connect(device_path, wait_for_services: true, timeout: timeout)
261
+
262
+ # Create Device from session for later operations
263
+ device = RBLE::BlueZ::Device.new_from_session(session, device_path)
264
+
265
+ # Store connected device (thread-safe)
266
+ @state_mutex.synchronize do
267
+ @connected_devices[device_path] = device
268
+ @device_sessions[device_path] = session
269
+ end
270
+ true
271
+ rescue RBLE::TimeoutError => e
272
+ session.stop_event_loop
273
+ session.disconnect
274
+ raise ConnectionTimeoutError.new(timeout)
275
+ rescue DBus::Error => e
276
+ session.stop_event_loop
277
+ session.disconnect
278
+ if e.name == 'org.freedesktop.DBus.Error.UnknownObject'
279
+ address = extract_address_from_path(device_path)
280
+ raise DeviceNotFoundError.new(address)
281
+ end
282
+ raise ConnectionError, "Failed to connect: #{e.message}"
283
+ rescue => e
284
+ session.stop_event_loop
285
+ session.disconnect
286
+ raise
287
+ end
288
+ end
289
+
290
+ # Disconnect from a BLE device
291
+ # @param device_path [String] D-Bus device path
292
+ # @return [void]
293
+ def disconnect_device(device_path)
294
+ device, session = @state_mutex.synchronize do
295
+ dev = @connected_devices.delete(device_path)
296
+ sess = @device_sessions.delete(device_path)
297
+ @device_services.delete(device_path)
298
+ @subscriptions.delete_if { |char_path, _| char_path.start_with?(device_path) }
299
+ [dev, sess]
300
+ end
301
+ return unless device
302
+
303
+ # Unregister connection for disconnect monitoring
304
+ unregister_connection(device_path)
305
+
306
+ begin
307
+ # Use async disconnect if session available
308
+ if session
309
+ session.async_disconnect(device_path, timeout: 5) rescue nil
310
+ session.stop_event_loop rescue nil
311
+ session.disconnect rescue nil
312
+ else
313
+ # Fallback to sync disconnect (legacy path)
314
+ device.disconnect rescue nil
315
+ end
316
+ rescue DBus::Error
317
+ # Ignore errors during cleanup - device may already be disconnected
318
+ end
319
+ end
320
+
321
+ # Register a Connection for disconnect monitoring
322
+ # @param device_path [String] D-Bus device path
323
+ # @param connection [Connection] Connection instance to notify on disconnect
324
+ # @return [void]
325
+ def register_connection(device_path, connection)
326
+ @state_mutex.synchronize { @connection_objects[device_path] = connection }
327
+ # Disconnect monitoring is now set up in Connection#setup_dbus_session
328
+ # BEFORE the event loop starts, to avoid blocking on_signal calls
329
+ end
330
+
331
+ # Unregister a Connection from disconnect monitoring
332
+ # @param device_path [String] D-Bus device path
333
+ # @return [void]
334
+ def unregister_connection(device_path)
335
+ @state_mutex.synchronize { @connection_objects.delete(device_path) }
336
+ # Signal handler cleanup happens automatically when device object is GC'd
337
+ end
338
+
339
+ # Discover GATT services on a connected device
340
+ # Uses the Connection's DBusSession for D-Bus operations
341
+ # @param device_path [String] D-Bus device path
342
+ # @param connection [Connection] Connection instance
343
+ # @param timeout [Numeric] Discovery timeout in seconds (default: 30)
344
+ # @return [Array<Service>] Discovered services with characteristics
345
+ # @raise [NotConnectedError] if device is not connected
346
+ # @raise [ServiceDiscoveryError] if discovery fails
347
+ def discover_services(device_path, connection:, timeout: 30)
348
+ device, cached_services = @state_mutex.synchronize do
349
+ [@connected_devices[device_path], @device_services[device_path]]
350
+ end
351
+ raise NotConnectedError unless device
352
+
353
+ # Return cached services if available
354
+ return cached_services if cached_services
355
+
356
+ # Use Connection's D-Bus session
357
+ session = connection.dbus_session
358
+ raise NotConnectedError, 'No active D-Bus session' unless session
359
+
360
+ # Check if services already resolved
361
+ unless device.services_resolved?
362
+ # Wait for ServicesResolved using the Connection's event loop
363
+ begin
364
+ wait_for_property_on_session(session, device_path, 'ServicesResolved', true, timeout)
365
+ rescue ConnectionTimeoutError
366
+ raise ServiceDiscoveryError, "Service discovery timed out after #{timeout} seconds"
367
+ end
368
+ end
369
+
370
+ # Enumerate services and characteristics using Connection's session
371
+ services = enumerate_services_from_session(session, device_path)
372
+ @state_mutex.synchronize { @device_services[device_path] = services }
373
+ services
374
+ end
375
+
376
+ # Get the device D-Bus path for a given MAC address
377
+ # Uses a temporary D-Bus connection for the lookup
378
+ # @param address [String] Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
379
+ # @param adapter [String, nil] Specific adapter name (e.g., "hci0")
380
+ # @return [String, nil] Device path or nil if not found
381
+ def device_path_for_address(address, adapter: nil)
382
+ conn = create_temporary_connection
383
+ om = conn.object_manager
384
+ managed = om.GetManagedObjects.first
385
+
386
+ # Normalize address for comparison
387
+ normalized = address.upcase
388
+
389
+ # Find device by address
390
+ managed.each do |path, interfaces|
391
+ next unless interfaces.key?(RBLE::BlueZ::DEVICE_INTERFACE)
392
+
393
+ # Filter by adapter if specified
394
+ if adapter
395
+ adapter_path = "/org/bluez/#{adapter}"
396
+ next unless path.start_with?(adapter_path)
397
+ end
398
+
399
+ device_props = interfaces[RBLE::BlueZ::DEVICE_INTERFACE]
400
+ device_address = device_props['Address']&.upcase
401
+
402
+ return path if device_address == normalized
403
+ end
404
+
405
+ nil
406
+ ensure
407
+ conn&.disconnect
408
+ end
409
+
410
+ # Read the device name from BlueZ Device1 properties
411
+ # @param device_path [String] D-Bus device path
412
+ # @return [String, nil] Device name or alias, nil if not found
413
+ def device_name(device_path)
414
+ conn = RBLE::BlueZ::DBusConnection.new
415
+ conn.connect
416
+ begin
417
+ managed = conn.object_manager.GetManagedObjects.first
418
+ device_ifaces = managed[device_path]
419
+ return nil unless device_ifaces
420
+ device_props = device_ifaces[RBLE::BlueZ::DEVICE_INTERFACE]
421
+ return nil unless device_props
422
+ device_props['Name'] || device_props['Alias']
423
+ ensure
424
+ conn.disconnect
425
+ end
426
+ end
427
+
428
+ # Read a characteristic value
429
+ # Uses the Connection's DBusSession for D-Bus operations
430
+ # @param char_path [String] D-Bus characteristic path
431
+ # @param connection [Connection] Connection that owns this characteristic
432
+ # @param timeout [Numeric] Read timeout in seconds (default: 5)
433
+ # @return [String] Binary string (ASCII-8BIT encoding)
434
+ # @raise [NotConnectedError] if device is not connected
435
+ # @raise [ReadError] if read fails
436
+ # @raise [TimeoutError] if operation times out
437
+ def read_characteristic(char_path, connection:, timeout: 5)
438
+ # Check connection before attempting read (thread-safe)
439
+ device_path = extract_device_path(char_path)
440
+ connected = @state_mutex.synchronize { device_path && @connected_devices.key?(device_path) }
441
+ raise NotConnectedError unless connected
442
+
443
+ # Use Connection's D-Bus session with async GATT operation
444
+ session = connection.dbus_session
445
+ raise NotConnectedError, 'No active D-Bus session' unless session
446
+
447
+ # Async read - does not block event loop, handles timeout internally
448
+ session.async_read_value(char_path, timeout: timeout)
449
+ rescue NotConnectedError
450
+ # Handle unexpected disconnect
451
+ handle_unexpected_disconnect(device_path) if device_path
452
+ raise
453
+ end
454
+
455
+ # Write a value to a characteristic
456
+ # Uses the Connection's DBusSession for D-Bus operations
457
+ # @param char_path [String] D-Bus characteristic path
458
+ # @param data [String, Array<Integer>] Data to write
459
+ # @param connection [Connection] Connection that owns this characteristic
460
+ # @param response [Boolean] Wait for write response (true = 'request', false = 'command')
461
+ # @param timeout [Numeric] Write timeout in seconds (default: 5)
462
+ # @return [Boolean] true on success
463
+ # @raise [NotConnectedError] if device is not connected
464
+ # @raise [WriteError] if write fails
465
+ # @raise [TimeoutError] if operation times out
466
+ def write_characteristic(char_path, data, connection:, response: true, timeout: 5)
467
+ # Check connection before attempting write (thread-safe)
468
+ device_path = extract_device_path(char_path)
469
+ connected = @state_mutex.synchronize { device_path && @connected_devices.key?(device_path) }
470
+ raise NotConnectedError unless connected
471
+
472
+ # Use Connection's D-Bus session with async GATT operation
473
+ session = connection.dbus_session
474
+ raise NotConnectedError, 'No active D-Bus session' unless session
475
+
476
+ # Convert string to bytes array if needed
477
+ bytes = data.is_a?(String) ? data.bytes : data
478
+
479
+ # Async write - does not block event loop, handles timeout internally
480
+ session.async_write_value(char_path, bytes, response: response, timeout: timeout)
481
+ rescue NotConnectedError
482
+ # Handle unexpected disconnect
483
+ handle_unexpected_disconnect(device_path) if device_path
484
+ raise
485
+ end
486
+
487
+ # Subscribe to characteristic notifications
488
+ # Uses the Connection's DBusSession for D-Bus operations and event delivery
489
+ # @param char_path [String] D-Bus characteristic path
490
+ # @param connection [Connection] Connection that owns this characteristic
491
+ # @param timeout [Numeric] Timeout for StartNotify call in seconds (default: 5)
492
+ # @yield [String] Called with value (binary string) on each notification
493
+ # @return [Boolean] true on success
494
+ # @raise [NotConnectedError] if device is not connected
495
+ # @raise [NotifyError] if subscription fails
496
+ # @raise [TimeoutError] if operation times out
497
+ def subscribe_characteristic(char_path, connection:, timeout: 5, &callback)
498
+ # Check connection and subscription state (thread-safe)
499
+ device_path = extract_device_path(char_path)
500
+ already_subscribed, connected = @state_mutex.synchronize do
501
+ [@subscriptions.key?(char_path), device_path && @connected_devices.key?(device_path)]
502
+ end
503
+
504
+ # Already subscribed - return early
505
+ return true if already_subscribed
506
+
507
+ raise NotConnectedError unless connected
508
+
509
+ # Use Connection's D-Bus session for both GATT operations and event delivery
510
+ session = connection.dbus_session
511
+ raise NotConnectedError, 'No active D-Bus session' unless session
512
+
513
+ RBLE.logger&.debug("[RBLE] Subscribing to #{char_path}")
514
+
515
+ # Async start notifications - does not block event loop
516
+ # Idempotent: returns true if already notifying (handled by async module)
517
+ session.async_start_notify(char_path, timeout: timeout)
518
+
519
+ RBLE.logger&.debug("[RBLE] StartNotify called for #{char_path}")
520
+
521
+ # Get introspected proxy for PropertiesChanged signal handler
522
+ # Uses cached introspection from async_start_notify call
523
+ proxy = session.async_introspect(char_path, timeout: timeout)
524
+ props_iface = proxy[RBLE::BlueZ::PROPERTIES_INTERFACE]
525
+
526
+ # Validate introspection succeeded - Properties interface must have Get method
527
+ unless props_iface.respond_to?(:Get)
528
+ raise RBLE::Error, "Introspection incomplete for #{char_path}: Properties interface has no Get method. " \
529
+ "This may indicate corrupted D-Bus introspection data."
530
+ end
531
+
532
+ # Get UUID from characteristic properties - use async call to avoid deadlock
533
+ char_uuid = session.async_get_property(
534
+ char_path, RBLE::BlueZ::GATT_CHARACTERISTIC_INTERFACE, 'UUID', timeout: timeout
535
+ )
536
+
537
+ # Subscribe to PropertiesChanged signal for value updates
538
+ # Events are enqueued to the Connection's event loop
539
+ # Use async registration to avoid deadlock — on_signal does synchronous
540
+ # AddMatch which blocks if event loop is running on the same connection.
541
+ session.async_register_signal_handler(props_iface, 'PropertiesChanged') do |interface, changed, _invalidated|
542
+ next unless interface == RBLE::BlueZ::GATT_CHARACTERISTIC_INTERFACE
543
+ next unless changed.key?('Value')
544
+
545
+ # Convert value bytes to binary string
546
+ value = changed['Value'].map(&:to_i).pack('C*')
547
+
548
+ RBLE.logger&.debug("[RBLE] Notification received: #{char_path} (#{value.bytesize} bytes)")
549
+ RBLE.logger&.debug("[RBLE] Data: #{value.bytes.map { |b| format('%02x', b) }.join(' ')}")
550
+
551
+ # Enqueue to Connection's event loop for thread-safe callback dispatch
552
+ # Using Connection's dbus_session ensures notifications are delivered
553
+ # even when scan session is destroyed
554
+ session.enqueue(:notification, char_path, { value: value, callback: callback })
555
+ end
556
+
557
+ # Store subscription for tracking (thread-safe)
558
+ # Include connection reference, UUID, and proxy for cleanup
559
+ @state_mutex.synchronize do
560
+ @subscriptions[char_path] = {
561
+ callback: callback,
562
+ proxy: proxy,
563
+ connection: connection,
564
+ uuid: char_uuid
565
+ }
566
+ end
567
+
568
+ # Start background notification processing on first subscribe
569
+ connection.ensure_notification_processing
570
+
571
+ true
572
+ rescue NotConnectedError
573
+ # Handle unexpected disconnect
574
+ handle_unexpected_disconnect(device_path) if device_path
575
+ raise
576
+ end
577
+
578
+ # Get active subscriptions for a connection
579
+ # @param connection [Connection] Connection to query
580
+ # @return [Array<Hash>] Subscription info hashes with :uuid and :path
581
+ def subscriptions_for_connection(connection)
582
+ @state_mutex.synchronize do
583
+ @subscriptions.select { |_path, sub| sub[:connection] == connection }
584
+ .map do |path, sub|
585
+ { path: path, uuid: sub[:uuid] }
586
+ end
587
+ end
588
+ end
589
+
590
+ # Unsubscribe from characteristic notifications
591
+ # @param char_path [String] D-Bus characteristic path
592
+ # @param connection [Connection] Connection that owns this subscription
593
+ # @return [Boolean] true on success
594
+ # @raise [NotConnectedError] if device is not connected
595
+ def unsubscribe_characteristic(char_path, connection: nil)
596
+ RBLE.logger&.debug("[RBLE] Unsubscribing from #{char_path}")
597
+
598
+ device_path = extract_device_path(char_path)
599
+ subscription, connected = @state_mutex.synchronize do
600
+ sub = @subscriptions.delete(char_path)
601
+ conn = device_path && @connected_devices.key?(device_path)
602
+ [sub, conn]
603
+ end
604
+ return true unless subscription
605
+
606
+ # Check connection before attempting unsubscribe
607
+ raise NotConnectedError unless connected
608
+
609
+ # Use provided connection or fall back to stored one
610
+ conn = connection || subscription[:connection]
611
+ session = conn&.dbus_session
612
+ return true unless session
613
+
614
+ begin
615
+ # Async stop notify - does not block event loop
616
+ session.async_stop_notify(char_path, timeout: 5)
617
+ RBLE.logger&.debug("[RBLE] StopNotify called for #{char_path}")
618
+ rescue NotConnectedError
619
+ # Handle unexpected disconnect
620
+ handle_unexpected_disconnect(device_path) if device_path
621
+ raise
622
+ rescue StandardError => e
623
+ RBLE.logger&.debug("[RBLE] Error during unsubscribe cleanup: #{e.class}: #{e.message}")
624
+ end
625
+
626
+ true
627
+ end
628
+
629
+ # Pair with a BLE device
630
+ #
631
+ # Creates a PairingSession with a dedicated D-Bus bus, exports a PairingAgent,
632
+ # and drives the Pair() call with agent callback processing.
633
+ #
634
+ # @param device_path [String] D-Bus device path
635
+ # @param io_handler [#display, #prompt, #confirm] IO handler for user interaction
636
+ # @param force [Boolean] Skip interactive prompts (auto-accept)
637
+ # @param capability [String, nil] Security capability level ("low", "medium", "high")
638
+ # @param timeout [Numeric] Pairing timeout in seconds
639
+ # @return [Symbol] :paired on success, :already_paired if already paired
640
+ # @raise [RBLE::AuthenticationError] if authentication fails
641
+ # @raise [RBLE::ConnectionFailed] if connection to device fails
642
+ # @raise [RBLE::Error] on other failures
643
+ def pair_device(device_path, io_handler:, force: false, capability: nil, timeout: 30)
644
+ # Check if already paired via D-Bus properties
645
+ if device_already_paired?(device_path)
646
+ return :already_paired
647
+ end
648
+
649
+ mapped_capability = RBLE::BlueZ::PairingSession::CAPABILITY_MAP.fetch(
650
+ capability,
651
+ RBLE::BlueZ::PairingSession::CAPABILITY_MAP[nil]
652
+ )
653
+
654
+ session = RBLE::BlueZ::PairingSession.new(
655
+ io_handler: io_handler,
656
+ force: force,
657
+ capability: mapped_capability
658
+ )
659
+ session.pair(device_path, timeout: timeout)
660
+ end
661
+
662
+ # Remove pairing bond from a device
663
+ #
664
+ # If the device is connected, disconnects first. Uses Adapter1.RemoveDevice
665
+ # to remove the pairing bond. Idempotent: returns :not_paired if device is
666
+ # not known to BlueZ.
667
+ #
668
+ # @param device_path [String] D-Bus device path
669
+ # @return [Symbol] :unpaired on success, :not_paired if not paired
670
+ # @raise [RBLE::Error] on failure
671
+ def unpair_device(device_path)
672
+ conn = RBLE::BlueZ::DBusConnection.new
673
+ conn.connect
674
+
675
+ begin
676
+ managed = conn.object_manager.GetManagedObjects.first
677
+
678
+ # Check if device exists in BlueZ
679
+ device_ifaces = managed[device_path]
680
+ unless device_ifaces && device_ifaces.key?(RBLE::BlueZ::DEVICE_INTERFACE)
681
+ return :not_paired
682
+ end
683
+
684
+ # If connected, disconnect first
685
+ device_props = device_ifaces[RBLE::BlueZ::DEVICE_INTERFACE]
686
+ if device_props['Connected']
687
+ begin
688
+ session = RBLE::BlueZ::DBusSession.new
689
+ session.connect
690
+ session.start_event_loop
691
+ session.async_disconnect(device_path, timeout: 5)
692
+ rescue StandardError => e
693
+ RBLE.logger&.debug("[RBLE] Error disconnecting before unpair: #{e.message}")
694
+ ensure
695
+ session&.close
696
+ end
697
+ end
698
+
699
+ # Extract adapter path from device path
700
+ # Device path: /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX
701
+ # Adapter path: /org/bluez/hci0
702
+ parts = device_path.split('/')
703
+ dev_index = parts.index { |p| p.start_with?('dev_') }
704
+ adapter_path = parts[0...dev_index].join('/')
705
+
706
+ # Call Adapter1.RemoveDevice
707
+ adapter_obj = conn.object(adapter_path)
708
+ adapter_iface = adapter_obj[RBLE::BlueZ::ADAPTER_INTERFACE]
709
+ adapter_iface.RemoveDevice(device_path)
710
+
711
+ :unpaired
712
+ ensure
713
+ conn.disconnect
714
+ end
715
+ rescue DBus::Error => e
716
+ if e.name == 'org.bluez.Error.DoesNotExist'
717
+ :not_paired
718
+ else
719
+ raise RBLE::Error, "Failed to unpair device: #{e.message}"
720
+ end
721
+ end
722
+
723
+ # List all bonded/paired devices
724
+ #
725
+ # Queries BlueZ via GetManagedObjects and filters for devices that have
726
+ # Bonded == true or Paired == true.
727
+ #
728
+ # @return [Array<Hash>] Sorted array of device info hashes with:
729
+ # :address, :name, :paired, :bonded, :connected, :trusted, :address_type, :rssi, :icon, :adapter
730
+ # @raise [RBLE::Error] on failure
731
+ def bonded_devices
732
+ conn = RBLE::BlueZ::DBusConnection.new
733
+ conn.connect
734
+
735
+ begin
736
+ managed = conn.object_manager.GetManagedObjects.first
737
+
738
+ devices = []
739
+ managed.each do |path, ifaces|
740
+ next unless ifaces.key?(RBLE::BlueZ::DEVICE_INTERFACE)
741
+
742
+ props = ifaces[RBLE::BlueZ::DEVICE_INTERFACE]
743
+ paired = props['Paired'] == true
744
+ bonded = props['Bonded'] == true
745
+
746
+ next unless paired || bonded
747
+
748
+ # Extract adapter name from path
749
+ parts = path.split('/')
750
+ dev_index = parts.index { |p| p.start_with?('dev_') }
751
+ adapter_name = dev_index ? parts[dev_index - 1] : nil
752
+
753
+ devices << {
754
+ address: props['Address'],
755
+ name: props['Name'] || props['Alias'],
756
+ paired: paired,
757
+ bonded: bonded,
758
+ connected: props['Connected'] == true,
759
+ trusted: props['Trusted'] == true,
760
+ address_type: props['AddressType'] || 'public',
761
+ rssi: props['RSSI'],
762
+ icon: props['Icon'],
763
+ adapter: adapter_name
764
+ }
765
+ end
766
+
767
+ devices.sort_by { |d| d[:address].to_s }
768
+ ensure
769
+ conn.disconnect
770
+ end
771
+ rescue StandardError => e
772
+ raise RBLE::Error, "Failed to list bonded devices: #{e.message}"
773
+ end
774
+
775
+ private
776
+
777
+ # Best-effort disconnect of all tracked connections during process exit
778
+ # @return [void]
779
+ def cleanup_all_connections
780
+ connections = @state_mutex.synchronize { @connection_objects.values.dup }
781
+ connections.each do |conn|
782
+ conn.disconnect if conn.connected?
783
+ rescue StandardError
784
+ # best-effort cleanup during exit
785
+ end
786
+ end
787
+
788
+ # Check if a device is already paired via D-Bus properties
789
+ # @param device_path [String] D-Bus device path
790
+ # @return [Boolean] true if device is paired
791
+ def device_already_paired?(device_path)
792
+ conn = RBLE::BlueZ::DBusConnection.new
793
+ conn.connect
794
+ begin
795
+ managed = conn.object_manager.GetManagedObjects.first
796
+ device_ifaces = managed[device_path]
797
+ return false unless device_ifaces
798
+
799
+ props = device_ifaces[RBLE::BlueZ::DEVICE_INTERFACE]
800
+ return false unless props
801
+
802
+ props['Paired'] == true
803
+ ensure
804
+ conn.disconnect
805
+ end
806
+ rescue StandardError
807
+ false
808
+ end
809
+
810
+ # Extract device path from characteristic path
811
+ # @param char_path [String] Characteristic path
812
+ # Format: /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/serviceXXXX/charXXXX
813
+ # @return [String, nil] Device path or nil if not extractable
814
+ def extract_device_path(char_path)
815
+ return nil unless char_path
816
+
817
+ # Find the device portion (ends at /service)
818
+ parts = char_path.split('/')
819
+ device_index = parts.index { |p| p.start_with?('dev_') }
820
+ return nil unless device_index
821
+
822
+ parts[0..device_index].join('/')
823
+ end
824
+
825
+ # Handle unexpected disconnect detected via PropertiesChanged signal
826
+ # @param device_path [String] D-Bus device path
827
+ def handle_unexpected_disconnect(device_path)
828
+ # Get connection and clean up state atomically (thread-safe)
829
+ connection = @state_mutex.synchronize do
830
+ conn = @connection_objects.delete(device_path)
831
+ return unless conn
832
+
833
+ # Clean up tracking state
834
+ @connected_devices.delete(device_path)
835
+ @device_services.delete(device_path)
836
+
837
+ # Clear any active subscriptions for this device's characteristics
838
+ # Use delete_if to avoid modifying hash during iteration
839
+ @subscriptions.delete_if { |char_path, _| char_path.start_with?(device_path) }
840
+
841
+ conn
842
+ end
843
+
844
+ # Notify the Connection object with link_loss reason
845
+ # Called OUTSIDE mutex to prevent deadlock if callback accesses backend
846
+ connection.handle_disconnect(:link_loss)
847
+ end
848
+
849
+ # Setup adapter for scanning using the given session
850
+ # @param session [DBusSession] D-Bus session to use
851
+ # @param adapter_name [String, nil] Adapter name (e.g., "hci0")
852
+ # @return [String] Adapter path
853
+ def setup_adapter_for_scan(session, adapter_name)
854
+ adapter_path = if adapter_name
855
+ "/org/bluez/#{adapter_name}"
856
+ else
857
+ # Find first available adapter using async call (avoids deadlock)
858
+ managed = session.async_get_managed_objects(timeout: 10)
859
+ adapter_entry = managed.find { |_path, ifaces| ifaces.key?(RBLE::BlueZ::ADAPTER_INTERFACE) }
860
+ raise AdapterNotFoundError unless adapter_entry
861
+
862
+ adapter_entry.first
863
+ end
864
+
865
+ adapter = RBLE::BlueZ::Adapter.new_from_session(session, adapter_path)
866
+ raise AdapterDisabledError, adapter.name unless adapter.powered?
867
+
868
+ adapter_path
869
+ end
870
+
871
+ # Setup signal handlers for scanning using the scan session
872
+ # All handlers are registered BEFORE the event loop starts to avoid
873
+ # synchronous AddMatch deadlock with the event loop reading the same socket.
874
+ # @param session [DBusSession] D-Bus session to use
875
+ def setup_scan_signal_handlers(session)
876
+ # Subscribe to InterfacesAdded for new device discovery
877
+ # Use object_manager which returns pre-introspected root (no sync call)
878
+ object_manager = session.object_manager
879
+
880
+ object_manager.on_signal('InterfacesAdded') do |path, interfaces|
881
+ # Capture session to prevent race with cleanup setting it to nil
882
+ scan_session = @scan_session
883
+ if interfaces.key?(RBLE::BlueZ::DEVICE_INTERFACE) && scan_session
884
+ scan_session.enqueue(:device_found, path, interfaces[RBLE::BlueZ::DEVICE_INTERFACE])
885
+ end
886
+ end
887
+ @signal_handlers << [:interfaces_added, object_manager]
888
+
889
+ object_manager.on_signal('InterfacesRemoved') do |path, interfaces|
890
+ # Capture session to prevent race with cleanup setting it to nil
891
+ scan_session = @scan_session
892
+ if interfaces.include?(RBLE::BlueZ::DEVICE_INTERFACE) && scan_session
893
+ scan_session.enqueue(:device_removed, path, nil)
894
+ end
895
+ end
896
+ @signal_handlers << [:interfaces_removed, object_manager]
897
+
898
+ # Broad PropertiesChanged for ALL device property updates during scan.
899
+ # A pathless MatchRule matches signals from any BlueZ object path.
900
+ # The handler filters by @scan_adapter_path at dispatch time.
901
+ # Replaces per-device subscribe_to_device_properties_for_scan which called
902
+ # on_signal after the event loop started, causing deadlock.
903
+ mr = DBus::MatchRule.new
904
+ mr.type = 'signal'
905
+ mr.interface = 'org.freedesktop.DBus.Properties'
906
+ mr.member = 'PropertiesChanged'
907
+ bus = session.bus
908
+ bus.add_match(mr) do |msg|
909
+ scan_session = @scan_session
910
+ next unless scan_session
911
+ next unless @scan_adapter_path && msg.path&.start_with?(@scan_adapter_path)
912
+
913
+ interface_name = msg.params[0]
914
+ changed = msg.params[1]
915
+ next unless interface_name == RBLE::BlueZ::DEVICE_INTERFACE
916
+
917
+ scan_session.enqueue(:properties_changed, msg.path, changed)
918
+ end
919
+ @signal_handlers << [:properties_changed_broad, mr]
920
+ end
921
+
922
+ # Start discovery on the scan session's adapter
923
+ # @param session [DBusSession] D-Bus session to use
924
+ # @param service_uuids [Array<String>, nil] Filter by service UUIDs
925
+ # @param allow_duplicates [Boolean] Callback on every advertisement
926
+ def start_discovery_on_session(session, service_uuids:, allow_duplicates:)
927
+ adapter = RBLE::BlueZ::Adapter.new_from_session(session, @scan_adapter_path)
928
+ adapter.set_discovery_filter(
929
+ service_uuids: service_uuids,
930
+ allow_duplicates: allow_duplicates
931
+ )
932
+ adapter.start_discovery
933
+ end
934
+
935
+ # Process existing devices from a D-Bus session
936
+ # @param session [DBusSession] D-Bus session to use
937
+ def process_existing_devices_from_session(session)
938
+ # Use async call to avoid deadlock with event loop
939
+ managed = session.async_get_managed_objects(timeout: 10)
940
+
941
+ managed.each do |path, interfaces|
942
+ next unless interfaces.key?(RBLE::BlueZ::DEVICE_INTERFACE)
943
+ next unless path.start_with?(@scan_adapter_path)
944
+
945
+ device_props = interfaces[RBLE::BlueZ::DEVICE_INTERFACE]
946
+ handle_device_found(path, device_props)
947
+ end
948
+ end
949
+
950
+ def handle_event(event)
951
+ case event.type
952
+ when :device_found
953
+ handle_device_found(event.path, event.data)
954
+ when :device_removed
955
+ handle_device_removed(event.path)
956
+ when :properties_changed
957
+ handle_properties_changed(event.path, event.data)
958
+ when :notification
959
+ handle_notification(event.data)
960
+ when :error
961
+ raise event.data[:exception] if event.data&.key?(:exception)
962
+ end
963
+ end
964
+
965
+ def handle_notification(data)
966
+ return unless data.is_a?(Hash)
967
+
968
+ callback = data[:callback]
969
+ value = data[:value]
970
+ callback&.call(value)
971
+ end
972
+
973
+ def handle_device_found(path, properties)
974
+ return unless @scan_adapter_path && path.start_with?(@scan_adapter_path)
975
+
976
+ # Secondary UUID filter verification - BlueZ may ignore the filter in some cases
977
+ # Check if device advertises any of the requested service UUIDs
978
+ if @service_uuid_filter && !@service_uuid_filter.empty?
979
+ device_uuids = (properties['UUIDs'] || []).map { |u| normalize_uuid_for_filter(u) }
980
+ return unless @service_uuid_filter.any? { |filter_uuid| device_uuids.include?(filter_uuid) }
981
+ end
982
+
983
+ device = build_device(path, properties)
984
+
985
+ # Thread-safe check-then-act for known_devices
986
+ is_new = @state_mutex.synchronize do
987
+ new_device = !@known_devices.key?(path)
988
+ @known_devices[path] = device
989
+ new_device
990
+ end
991
+
992
+ # Callback if new device or allow_duplicates
993
+ return unless is_new || @allow_duplicates
994
+
995
+ @scan_callback&.call(device)
996
+ end
997
+
998
+ def handle_device_removed(path)
999
+ @state_mutex.synchronize { @known_devices.delete(path) }
1000
+ end
1001
+
1002
+ def handle_properties_changed(path, changed)
1003
+ updates = parse_property_updates(changed)
1004
+ return if updates.empty?
1005
+
1006
+ # Thread-safe update of known_devices
1007
+ new_device = @state_mutex.synchronize do
1008
+ old_device = @known_devices[path]
1009
+ return unless old_device
1010
+
1011
+ updated = old_device.update(**updates)
1012
+ @known_devices[path] = updated
1013
+ updated
1014
+ end
1015
+
1016
+ # Callback if allow_duplicates (for RSSI monitoring)
1017
+ @scan_callback&.call(new_device) if @allow_duplicates
1018
+ end
1019
+
1020
+ def build_device(path, properties)
1021
+ Device.new(
1022
+ address: properties['Address']&.upcase || extract_address_from_path(path),
1023
+ name: properties['Name'],
1024
+ rssi: properties['RSSI'],
1025
+ manufacturer_data: parse_manufacturer_data(properties['ManufacturerData']),
1026
+ manufacturer_data_raw: parse_manufacturer_data_raw(properties['ManufacturerData']),
1027
+ service_data: parse_service_data(properties['ServiceData']),
1028
+ service_uuids: properties['UUIDs'] || [],
1029
+ tx_power: properties['TxPower'],
1030
+ address_type: properties['AddressType'] || 'public'
1031
+ )
1032
+ end
1033
+
1034
+ def parse_property_updates(changed)
1035
+ updates = {}
1036
+ updates[:name] = changed['Name'] if changed.key?('Name')
1037
+ updates[:rssi] = changed['RSSI'] if changed.key?('RSSI')
1038
+ updates[:tx_power] = changed['TxPower'] if changed.key?('TxPower')
1039
+
1040
+ if changed.key?('ManufacturerData')
1041
+ updates[:manufacturer_data] = parse_manufacturer_data(changed['ManufacturerData'])
1042
+ updates[:manufacturer_data_raw] = parse_manufacturer_data_raw(changed['ManufacturerData'])
1043
+ end
1044
+
1045
+ updates[:service_data] = parse_service_data(changed['ServiceData']) if changed.key?('ServiceData')
1046
+
1047
+ updates[:service_uuids] = changed['UUIDs'] || [] if changed.key?('UUIDs')
1048
+
1049
+ updates
1050
+ end
1051
+
1052
+ # Parse ManufacturerData D-Bus dict (a{qay}) to Hash of byte arrays
1053
+ def parse_manufacturer_data(data)
1054
+ return {} if data.nil?
1055
+
1056
+ result = {}
1057
+ data.each do |company_id, bytes|
1058
+ # company_id is uint16, bytes is array of uint8
1059
+ result[company_id.to_i] = bytes.map(&:to_i)
1060
+ end
1061
+ result
1062
+ end
1063
+
1064
+ # Parse ManufacturerData to raw binary strings
1065
+ def parse_manufacturer_data_raw(data)
1066
+ return {} if data.nil?
1067
+
1068
+ result = {}
1069
+ data.each do |company_id, bytes|
1070
+ result[company_id.to_i] = bytes.map(&:to_i).pack('C*')
1071
+ end
1072
+ result
1073
+ end
1074
+
1075
+ # Parse ServiceData D-Bus dict (a{say}) to Hash of byte arrays
1076
+ def parse_service_data(data)
1077
+ return {} if data.nil?
1078
+
1079
+ result = {}
1080
+ data.each do |uuid, bytes|
1081
+ result[uuid.to_s] = bytes.map(&:to_i)
1082
+ end
1083
+ result
1084
+ end
1085
+
1086
+ # Extract MAC address from D-Bus path like /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF
1087
+ def extract_address_from_path(path)
1088
+ if path =~ /dev_([0-9A-F]{2}_[0-9A-F]{2}_[0-9A-F]{2}_[0-9A-F]{2}_[0-9A-F]{2}_[0-9A-F]{2})/i
1089
+ ::Regexp.last_match(1).tr('_', ':').upcase
1090
+ else
1091
+ 'UNKNOWN'
1092
+ end
1093
+ end
1094
+
1095
+ # Normalize UUID for filter comparison
1096
+ # Converts short UUIDs (e.g., "180d") to full 128-bit format
1097
+ # @param uuid [String] UUID in any format
1098
+ # @return [String] Lowercase 128-bit UUID
1099
+ def normalize_uuid_for_filter(uuid)
1100
+ uuid = uuid.to_s.downcase.delete('-')
1101
+ if uuid.length == 4
1102
+ # Short 16-bit UUID - expand to full 128-bit Bluetooth Base UUID
1103
+ "0000#{uuid}-0000-1000-8000-00805f9b34fb"
1104
+ elsif uuid.length == 8
1105
+ # 32-bit UUID - expand to full 128-bit Bluetooth Base UUID
1106
+ "#{uuid}-0000-1000-8000-00805f9b34fb"
1107
+ elsif uuid.length == 32
1108
+ # Full UUID without hyphens - add hyphens
1109
+ "#{uuid[0..7]}-#{uuid[8..11]}-#{uuid[12..15]}-#{uuid[16..19]}-#{uuid[20..31]}"
1110
+ else
1111
+ # Already formatted
1112
+ uuid.downcase
1113
+ end
1114
+ end
1115
+
1116
+ # Clean up scan-specific state by destroying the temporary scan session
1117
+ # After this, the scan D-Bus connection is never reused (fresh session per scan)
1118
+ def stop_scan_cleanup
1119
+ # Destroy the temporary scan session (stops event loop and closes D-Bus connection)
1120
+ # Signal handlers are automatically dropped when the D-Bus connection closes.
1121
+ # We don't call unsubscribe_signal_handlers here because remove_match is a
1122
+ # synchronous D-Bus call that deadlocks when the event loop is still running.
1123
+ @scan_session&.disconnect
1124
+ @scan_session = nil
1125
+ @scan_adapter_path = nil
1126
+
1127
+ # Clear signal handler tracking (handlers already invalidated by connection close)
1128
+ @signal_handlers.clear
1129
+
1130
+ # Clear scan-specific state only
1131
+ @state_mutex.synchronize do
1132
+ @scanning = false
1133
+ @known_devices.clear
1134
+ end
1135
+
1136
+ @scan_callback = nil
1137
+ @service_uuid_filter = nil
1138
+ end
1139
+
1140
+ # Full cleanup - resets all backend state
1141
+ # Called on errors or when backend needs to be completely reset
1142
+ def cleanup
1143
+ # Stop scan session if active
1144
+ # Signal handlers are automatically dropped when connection closes
1145
+ @scan_session&.disconnect
1146
+ @scan_session = nil
1147
+ @scan_adapter_path = nil
1148
+
1149
+ # Clear signal handler tracking (handlers already invalidated by connection close)
1150
+ @signal_handlers.clear
1151
+
1152
+ # Clear all shared state atomically
1153
+ @state_mutex.synchronize do
1154
+ @scanning = false
1155
+ @known_devices.clear
1156
+ @connection_objects.clear
1157
+ @connected_devices.clear
1158
+ @device_services.clear
1159
+ @subscriptions.clear
1160
+ end
1161
+
1162
+ @scan_callback = nil
1163
+ end
1164
+
1165
+ # Create a temporary D-Bus connection for one-shot operations
1166
+ # Used for operations that don't have a Connection context (e.g., connect_device, device_path_for_address)
1167
+ # The connection must be disconnected after use
1168
+ # @return [DBusConnection] New D-Bus connection
1169
+ def create_temporary_connection
1170
+ conn = RBLE::BlueZ::DBusConnection.new
1171
+ conn.connect
1172
+ conn
1173
+ end
1174
+
1175
+ # Wait for a property to change using a DBusSession
1176
+ # @param session [DBusSession] D-Bus session to use
1177
+ # @param device_path [String] Device path to watch
1178
+ # @param property [String] Property name (e.g., 'Connected', 'ServicesResolved')
1179
+ # @param expected_value [Object] Expected property value
1180
+ # @param timeout [Numeric] Timeout in seconds
1181
+ # @raise [ConnectionTimeoutError] if timeout exceeded
1182
+ def wait_for_property_on_session(session, device_path, property, expected_value, timeout)
1183
+ # Subscribe to PropertiesChanged on the device
1184
+ # Use async_introspect to avoid deadlock (Connection's event loop is running)
1185
+ device_obj = session.async_introspect(device_path, timeout: 5)
1186
+ props_iface = device_obj[RBLE::BlueZ::PROPERTIES_INTERFACE]
1187
+
1188
+ # Use async registration to avoid deadlock with running event loop
1189
+ session.async_register_signal_handler(props_iface, 'PropertiesChanged') do |interface, changed, _invalidated|
1190
+ session.enqueue(:properties_changed, device_path, changed) if interface == RBLE::BlueZ::DEVICE_INTERFACE
1191
+ end
1192
+
1193
+ deadline = Time.now + timeout
1194
+
1195
+ loop do
1196
+ remaining = deadline - Time.now
1197
+ raise ConnectionTimeoutError, timeout if remaining <= 0
1198
+
1199
+ # Process events with timeout
1200
+ shutdown = session.process_events(timeout: [remaining, 0.5].min) do |event|
1201
+ next unless event.type == :properties_changed
1202
+ next unless event.path == device_path
1203
+ next unless event.data.is_a?(Hash) && event.data.key?(property)
1204
+
1205
+ return true if event.data[property] == expected_value
1206
+ end
1207
+
1208
+ # Check if we received shutdown
1209
+ break if shutdown
1210
+ end
1211
+
1212
+ raise ConnectionTimeoutError, timeout
1213
+ end
1214
+
1215
+ # Enumerate GATT services and characteristics for a device using a DBusSession
1216
+ # @param session [DBusSession] D-Bus session to use
1217
+ # @param device_path [String] Device path
1218
+ # @return [Array<Hash>] Service data with characteristics and their paths
1219
+ # Each hash contains: :uuid, :primary, :characteristics (array of {data:, path:})
1220
+ def enumerate_services_from_session(session, device_path)
1221
+ # Use async call to avoid deadlock with event loop
1222
+ managed = session.async_get_managed_objects(timeout: 10)
1223
+
1224
+ # Find all services under this device
1225
+ services = []
1226
+ service_paths = managed.select do |path, ifaces|
1227
+ path.start_with?("#{device_path}/") && ifaces.key?(RBLE::BlueZ::GATT_SERVICE_INTERFACE)
1228
+ end
1229
+
1230
+ service_paths.each do |service_path, service_ifaces|
1231
+ service_props = service_ifaces[RBLE::BlueZ::GATT_SERVICE_INTERFACE]
1232
+ service_uuid = service_props['UUID']
1233
+ service_primary = service_props['Primary'] != false
1234
+
1235
+ # Find characteristics for this service
1236
+ characteristics = []
1237
+ char_paths = managed.select do |path, ifaces|
1238
+ path.start_with?("#{service_path}/") && ifaces.key?(RBLE::BlueZ::GATT_CHARACTERISTIC_INTERFACE)
1239
+ end
1240
+
1241
+ char_paths.each do |char_path, char_ifaces|
1242
+ char_props = char_ifaces[RBLE::BlueZ::GATT_CHARACTERISTIC_INTERFACE]
1243
+ char_uuid = char_props['UUID']
1244
+ char_flags = char_props['Flags'] || []
1245
+
1246
+ # Enumerate descriptors under this characteristic
1247
+ desc_entries = managed.select do |path, ifaces|
1248
+ path.start_with?("#{char_path}/") && ifaces.key?(RBLE::BlueZ::GATT_DESCRIPTOR_INTERFACE)
1249
+ end
1250
+
1251
+ descriptors = desc_entries.map do |desc_path, desc_ifaces|
1252
+ desc_props = desc_ifaces[RBLE::BlueZ::GATT_DESCRIPTOR_INTERFACE]
1253
+ { uuid: desc_props['UUID'], path: desc_path }
1254
+ end
1255
+
1256
+ characteristics << {
1257
+ data: Characteristic.new(
1258
+ uuid: char_uuid,
1259
+ flags: char_flags,
1260
+ service_uuid: service_uuid
1261
+ ),
1262
+ path: char_path,
1263
+ descriptors: descriptors
1264
+ }
1265
+ end
1266
+
1267
+ services << {
1268
+ uuid: service_uuid,
1269
+ primary: service_primary,
1270
+ characteristics: characteristics
1271
+ }
1272
+ end
1273
+
1274
+ services
1275
+ end
1276
+
1277
+ end
1278
+ end
1279
+ end