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,492 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dbus'
4
+ require_relative 'async_call'
5
+ require_relative 'async_introspection'
6
+
7
+ module RBLE
8
+ module BlueZ
9
+ # Provides async connection operations using AsyncCall + AsyncIntrospection patterns.
10
+ # Use this to perform device connection lifecycle operations without blocking the event loop.
11
+ #
12
+ # This module is part of the v0.4.0 async architecture. It builds on AsyncCall
13
+ # and AsyncIntrospection to provide non-blocking connection operations.
14
+ #
15
+ # Including class must:
16
+ # - Include AsyncCall module (provides async_call method)
17
+ # - Include AsyncIntrospection module (provides async_introspect method)
18
+ # - Have @service attribute pointing to D-Bus service
19
+ #
20
+ # @example
21
+ # include AsyncCall
22
+ # include AsyncIntrospection
23
+ # include AsyncConnectionOperations
24
+ #
25
+ # async_connect("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF")
26
+ # async_disconnect("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF")
27
+ # async_start_discovery("/org/bluez/hci0", filter: { transport: 'le' })
28
+ # async_stop_discovery("/org/bluez/hci0")
29
+ # value = async_get_property("/org/bluez/hci0", "org.bluez.Adapter1", "Powered")
30
+ # async_set_property("/org/bluez/hci0", "org.bluez.Adapter1", "Powered", true)
31
+ #
32
+ module AsyncConnectionOperations
33
+ DEVICE_INTERFACE = 'org.bluez.Device1'
34
+ ADAPTER_INTERFACE = 'org.bluez.Adapter1'
35
+ PROPERTIES_INTERFACE = 'org.freedesktop.DBus.Properties'
36
+
37
+ # Timeouts from CONTEXT.md
38
+ DEFAULT_CONNECT_TIMEOUT = 30
39
+ DEFAULT_DISCONNECT_TIMEOUT = 5
40
+ DEFAULT_DISCOVERY_TIMEOUT = 10
41
+ DEFAULT_PROPERTY_TIMEOUT = 5
42
+
43
+ # Delay after ServicesResolved to allow BlueZ to fully export GATT services to D-Bus.
44
+ # BlueZ signals ServicesResolved=true slightly before all service/characteristic
45
+ # objects are available via GetManagedObjects. This delay prevents race conditions.
46
+ SERVICES_RESOLVED_DELAY = 0.15
47
+
48
+ # Async connect to a BLE device
49
+ #
50
+ # @param device_path [String] D-Bus device path
51
+ # @param wait_for_services [Boolean] Wait for GATT services to be discovered (default: true)
52
+ # @param timeout [Numeric] Total timeout in seconds for the entire connection process (default: 30)
53
+ # @return [Boolean] true on success
54
+ # @raise [TimeoutError] if connection times out
55
+ # @raise [ConnectionFailed] if connection fails
56
+ # @raise [AdapterNotFoundError] if adapter not ready
57
+ def async_connect(device_path, wait_for_services: true, timeout: DEFAULT_CONNECT_TIMEOUT)
58
+ address = extract_address_from_path(device_path)
59
+ deadline = Time.now + timeout
60
+ warn " [RBLE] async_connect timeout=#{timeout}s deadline=#{deadline.strftime('%H:%M:%S')}" if RBLE.trace
61
+
62
+ proxy = async_introspect(device_path, timeout: remaining_timeout(deadline, timeout))
63
+ device_iface = proxy[DEVICE_INTERFACE]
64
+ props_iface = proxy[PROPERTIES_INTERFACE]
65
+
66
+ # Idempotent: check if already connected (use async to avoid deadlock)
67
+ connected = async_get_property(device_path, DEVICE_INTERFACE, 'Connected', timeout: remaining_timeout(deadline, timeout))
68
+ if connected
69
+ # If waiting for services and not resolved, wait
70
+ if wait_for_services
71
+ services_resolved = async_get_property(device_path, DEVICE_INTERFACE, 'ServicesResolved', timeout: remaining_timeout(deadline, timeout))
72
+ unless services_resolved
73
+ wait_for_services_resolved(props_iface, timeout: remaining_timeout(deadline, timeout))
74
+ # Delay to allow BlueZ to fully export GATT services to D-Bus
75
+ sleep(SERVICES_RESOLVED_DELAY)
76
+ end
77
+ end
78
+ return true
79
+ end
80
+
81
+ # Setup ServicesResolved watcher BEFORE calling Connect (avoid race per RESEARCH.md)
82
+ # Use async signal registration to avoid deadlock with running event loop
83
+ services_queue = nil
84
+ if wait_for_services
85
+ services_queue = Thread::Queue.new
86
+ async_register_signal_handler(props_iface, 'PropertiesChanged') do |interface, changed, _invalidated|
87
+ next unless interface == DEVICE_INTERFACE
88
+ if changed.key?('ServicesResolved') && changed['ServicesResolved'] == true
89
+ services_queue.push(true)
90
+ end
91
+ end
92
+ end
93
+
94
+ begin
95
+ # Call Connect asynchronously
96
+ connect_timeout = remaining_timeout(deadline, timeout)
97
+ async_call("Connect(#{address})", timeout: connect_timeout) do |queue, _request_id, cancelled|
98
+ device_iface.Connect do |reply|
99
+ next if cancelled[0] # Discard late callback
100
+ if reply.is_a?(DBus::Error)
101
+ queue.push([reply, nil])
102
+ else
103
+ queue.push([nil, :ok])
104
+ end
105
+ end
106
+ end
107
+
108
+ # Wait for ServicesResolved if requested
109
+ if wait_for_services && services_queue
110
+ # Check if already resolved (race condition protection)
111
+ # Use async_get_property to avoid deadlock
112
+ services_resolved = async_get_property(device_path, DEVICE_INTERFACE, 'ServicesResolved', timeout: remaining_timeout(deadline, timeout))
113
+ unless services_resolved
114
+ services_timeout = remaining_timeout(deadline, timeout)
115
+ result = services_queue.pop(timeout: services_timeout)
116
+ raise TimeoutError.new('ServicesResolved', timeout) if result.nil?
117
+ end
118
+ # Delay to allow BlueZ to fully export GATT services to D-Bus
119
+ sleep(SERVICES_RESOLVED_DELAY)
120
+ end
121
+
122
+ true
123
+ ensure
124
+ # Unsubscribe signal handler asynchronously (safe while event loop runs)
125
+ async_unregister_signal_handler(props_iface, 'PropertiesChanged') if wait_for_services
126
+ end
127
+ rescue DBus::Error => e
128
+ translate_connection_error(e, device_path)
129
+ end
130
+
131
+ # Async disconnect from a BLE device
132
+ #
133
+ # @param device_path [String] D-Bus device path
134
+ # @param timeout [Numeric] Total timeout in seconds for the entire disconnect operation (default: 5)
135
+ # @return [Boolean] true on success
136
+ # @raise [TimeoutError] if disconnect times out
137
+ def async_disconnect(device_path, timeout: DEFAULT_DISCONNECT_TIMEOUT)
138
+ address = extract_address_from_path(device_path)
139
+ deadline = Time.now + timeout
140
+
141
+ proxy = async_introspect(device_path, timeout: remaining_timeout(deadline, timeout, 'Disconnect'))
142
+ device_iface = proxy[DEVICE_INTERFACE]
143
+
144
+ # Idempotent: check if not connected (use async to avoid deadlock)
145
+ connected = async_get_property(device_path, DEVICE_INTERFACE, 'Connected', timeout: remaining_timeout(deadline, timeout, 'Disconnect'))
146
+ return true unless connected
147
+
148
+ async_call("Disconnect(#{address})", timeout: remaining_timeout(deadline, timeout, 'Disconnect')) do |queue, _request_id, cancelled|
149
+ device_iface.Disconnect do |reply|
150
+ next if cancelled[0] # Discard late callback
151
+ if reply.is_a?(DBus::Error)
152
+ queue.push([reply, nil])
153
+ else
154
+ queue.push([nil, :ok])
155
+ end
156
+ end
157
+ end
158
+
159
+ true
160
+ rescue DBus::Error => e
161
+ translate_connection_error(e, device_path)
162
+ end
163
+
164
+ # Async start discovery on an adapter
165
+ #
166
+ # @param adapter_path [String] D-Bus adapter path
167
+ # @param filter [Hash] Discovery filter options
168
+ # @option filter [String] :transport Transport type ('auto', 'bredr', 'le')
169
+ # @option filter [Array<String>] :uuids Service UUIDs to filter
170
+ # @option filter [Integer] :rssi Minimum RSSI value
171
+ # @option filter [Integer] :pathloss Maximum path loss
172
+ # @option filter [Boolean] :duplicate_data Report duplicate advertisements
173
+ # @param timeout [Numeric] Total timeout in seconds for the entire operation (default: 10)
174
+ # @return [Boolean] true on success
175
+ # @raise [TimeoutError] if operation times out
176
+ # @raise [ScanError] if discovery fails
177
+ # @raise [AdapterNotFoundError] if adapter not ready
178
+ def async_start_discovery(adapter_path, filter: {}, timeout: DEFAULT_DISCOVERY_TIMEOUT)
179
+ deadline = Time.now + timeout
180
+
181
+ proxy = async_introspect(adapter_path, timeout: remaining_timeout(deadline, timeout, 'StartDiscovery'))
182
+ adapter_iface = proxy[ADAPTER_INTERFACE]
183
+
184
+ # Idempotent: check if already discovering (use async to avoid deadlock)
185
+ discovering = async_get_property(adapter_path, ADAPTER_INTERFACE, 'Discovering', timeout: remaining_timeout(deadline, timeout, 'StartDiscovery'))
186
+ return true if discovering
187
+
188
+ # Set discovery filter first if any options provided
189
+ unless filter.empty?
190
+ async_set_discovery_filter(adapter_path, adapter_iface, filter, timeout: remaining_timeout(deadline, timeout, 'StartDiscovery'))
191
+ end
192
+
193
+ async_call("StartDiscovery(#{adapter_path})", timeout: remaining_timeout(deadline, timeout, 'StartDiscovery')) do |queue, _request_id, cancelled|
194
+ adapter_iface.StartDiscovery do |reply|
195
+ next if cancelled[0] # Discard late callback
196
+ if reply.is_a?(DBus::Error)
197
+ queue.push([reply, nil])
198
+ else
199
+ queue.push([nil, :ok])
200
+ end
201
+ end
202
+ end
203
+
204
+ true
205
+ rescue DBus::Error => e
206
+ # Idempotent: "InProgress" means discovery already started (race condition)
207
+ # This can happen if another process starts discovery between our check and the call
208
+ return true if e.name == 'org.bluez.Error.InProgress'
209
+
210
+ translate_discovery_error(e, adapter_path)
211
+ end
212
+
213
+ # Async stop discovery on an adapter
214
+ #
215
+ # @param adapter_path [String] D-Bus adapter path
216
+ # @param timeout [Numeric] Total timeout in seconds for the entire operation (default: 10)
217
+ # @return [Boolean] true on success
218
+ # @raise [TimeoutError] if operation times out
219
+ def async_stop_discovery(adapter_path, timeout: DEFAULT_DISCOVERY_TIMEOUT)
220
+ deadline = Time.now + timeout
221
+
222
+ proxy = async_introspect(adapter_path, timeout: remaining_timeout(deadline, timeout, 'StopDiscovery'))
223
+ adapter_iface = proxy[ADAPTER_INTERFACE]
224
+
225
+ # Idempotent: check if not discovering (use async to avoid deadlock)
226
+ discovering = async_get_property(adapter_path, ADAPTER_INTERFACE, 'Discovering', timeout: remaining_timeout(deadline, timeout, 'StopDiscovery'))
227
+ return true unless discovering
228
+
229
+ async_call("StopDiscovery(#{adapter_path})", timeout: remaining_timeout(deadline, timeout, 'StopDiscovery')) do |queue, _request_id, cancelled|
230
+ adapter_iface.StopDiscovery do |reply|
231
+ next if cancelled[0] # Discard late callback
232
+ if reply.is_a?(DBus::Error)
233
+ queue.push([reply, nil])
234
+ else
235
+ queue.push([nil, :ok])
236
+ end
237
+ end
238
+ end
239
+
240
+ true
241
+ rescue DBus::Error => e
242
+ # Idempotent: "No discovery started" means discovery already stopped (race condition)
243
+ # This can happen if BlueZ auto-stops discovery between our check and the call
244
+ return true if e.message.include?('No discovery started')
245
+
246
+ translate_discovery_error(e, adapter_path)
247
+ end
248
+
249
+ # Async get any D-Bus property value
250
+ #
251
+ # @param object_path [String] D-Bus object path
252
+ # @param interface [String] D-Bus interface name
253
+ # @param property [String] Property name
254
+ # @param timeout [Numeric] Timeout in seconds (default: 5)
255
+ # @return [Object] Property value (unwrapped from variant)
256
+ # @raise [TimeoutError] if operation times out
257
+ def async_get_property(object_path, interface, property, timeout: DEFAULT_PROPERTY_TIMEOUT)
258
+ proxy = async_introspect(object_path, timeout: timeout)
259
+ props_iface = proxy[PROPERTIES_INTERFACE]
260
+
261
+ # Validate introspection succeeded - Properties interface must have Get method
262
+ # This can fail if introspection returned truncated/corrupt XML
263
+ # Note: Don't call refresh_introspection here as it can invalidate proxies
264
+ # that other code is using (e.g., async_start_notify gets char_iface before
265
+ # calling async_get_property for Flags check)
266
+ unless props_iface.respond_to?(:Get)
267
+ raise RBLE::Error, "Introspection incomplete for #{object_path}: Properties interface has no Get method. " \
268
+ "This may indicate corrupted D-Bus introspection data."
269
+ end
270
+
271
+ result = async_call("Get(#{interface}.#{property})", timeout: timeout) do |queue, _request_id, cancelled|
272
+ props_iface.Get(interface, property) do |reply|
273
+ next if cancelled[0] # Discard late callback
274
+ if reply.is_a?(DBus::Error)
275
+ queue.push([reply, nil])
276
+ else
277
+ # Extract params from DBus::Message - Get returns [Variant]
278
+ params = reply.respond_to?(:params) ? reply.params : [reply]
279
+ queue.push([nil, params.first])
280
+ end
281
+ end
282
+ end
283
+
284
+ # D-Bus Properties.Get returns value (already extracted from params)
285
+ result
286
+ rescue DBus::Error => e
287
+ raise TimeoutError.new("Get #{interface}.#{property}", timeout) if e.message.include?('timeout')
288
+ raise e
289
+ end
290
+
291
+ # Async set any writable D-Bus property
292
+ #
293
+ # @param object_path [String] D-Bus object path
294
+ # @param interface [String] D-Bus interface name
295
+ # @param property [String] Property name
296
+ # @param value [Object] Value to set
297
+ # @param timeout [Numeric] Timeout in seconds (default: 5)
298
+ # @return [Boolean] true on success
299
+ # @raise [TimeoutError] if operation times out
300
+ def async_set_property(object_path, interface, property, value, timeout: DEFAULT_PROPERTY_TIMEOUT)
301
+ proxy = async_introspect(object_path, timeout: timeout)
302
+ props_iface = proxy[PROPERTIES_INTERFACE]
303
+
304
+ # Wrap value in DBus::Data::Variant with inferred type
305
+ variant_value = create_variant(value)
306
+
307
+ async_call("Set(#{interface}.#{property})", timeout: timeout) do |queue, _request_id, cancelled|
308
+ props_iface.Set(interface, property, variant_value) do |reply|
309
+ next if cancelled[0] # Discard late callback
310
+ if reply.is_a?(DBus::Error)
311
+ queue.push([reply, nil])
312
+ else
313
+ queue.push([nil, :ok])
314
+ end
315
+ end
316
+ end
317
+
318
+ true
319
+ rescue DBus::Error => e
320
+ raise TimeoutError.new("Set #{interface}.#{property}", timeout) if e.message.include?('timeout')
321
+ raise e
322
+ end
323
+
324
+ private
325
+
326
+ # Calculate remaining timeout from deadline, raising TimeoutError if expired
327
+ # @param deadline [Time] Absolute deadline
328
+ # @param original_timeout [Numeric] Original timeout for error message
329
+ # @return [Numeric] Remaining seconds (minimum 0.1 to allow one more attempt)
330
+ # @raise [TimeoutError] if deadline has passed
331
+ def remaining_timeout(deadline, original_timeout, operation = 'Connect')
332
+ remaining = deadline - Time.now
333
+ raise TimeoutError.new(operation, original_timeout) if remaining <= 0
334
+ [remaining, 0.1].max
335
+ end
336
+
337
+ # Wait for ServicesResolved property to become true
338
+ def wait_for_services_resolved(props_iface, timeout:)
339
+ queue = Thread::Queue.new
340
+
341
+ # Use async signal registration to avoid deadlock with running event loop
342
+ async_register_signal_handler(props_iface, 'PropertiesChanged') do |interface, changed, _invalidated|
343
+ next unless interface == DEVICE_INTERFACE
344
+ if changed.key?('ServicesResolved') && changed['ServicesResolved'] == true
345
+ queue.push(true)
346
+ end
347
+ end
348
+
349
+ begin
350
+ result = queue.pop(timeout: timeout)
351
+ raise TimeoutError.new('ServicesResolved', timeout) if result.nil?
352
+ ensure
353
+ async_unregister_signal_handler(props_iface, 'PropertiesChanged')
354
+ end
355
+ end
356
+
357
+ # Set discovery filter with correct D-Bus variant types
358
+ def async_set_discovery_filter(adapter_path, adapter_iface, options, timeout:)
359
+ filter = {}
360
+
361
+ # Transport: STRING
362
+ if options[:transport]
363
+ filter['Transport'] = DBus::Data::Variant.new(
364
+ options[:transport].to_s,
365
+ member_type: DBus::Type::STRING
366
+ )
367
+ end
368
+
369
+ # UUIDs: Array[STRING]
370
+ if options[:uuids] && !options[:uuids].empty?
371
+ filter['UUIDs'] = DBus::Data::Variant.new(
372
+ options[:uuids],
373
+ member_type: DBus::Type::Array[DBus::Type::STRING]
374
+ )
375
+ end
376
+
377
+ # RSSI: INT16
378
+ if options[:rssi]
379
+ filter['RSSI'] = DBus::Data::Variant.new(
380
+ options[:rssi].to_i,
381
+ member_type: DBus::Type::INT16
382
+ )
383
+ end
384
+
385
+ # Pathloss: UINT16
386
+ if options[:pathloss]
387
+ filter['Pathloss'] = DBus::Data::Variant.new(
388
+ options[:pathloss].to_i,
389
+ member_type: DBus::Type::UINT16
390
+ )
391
+ end
392
+
393
+ # DuplicateData: BOOLEAN
394
+ if options.key?(:duplicate_data)
395
+ filter['DuplicateData'] = DBus::Data::Variant.new(
396
+ !!options[:duplicate_data],
397
+ member_type: DBus::Type::BOOLEAN
398
+ )
399
+ end
400
+
401
+ async_call("SetDiscoveryFilter(#{adapter_path})", timeout: timeout) do |queue, _request_id, cancelled|
402
+ adapter_iface.SetDiscoveryFilter(filter) do |reply|
403
+ next if cancelled[0] # Discard late callback
404
+ if reply.is_a?(DBus::Error)
405
+ queue.push([reply, nil])
406
+ else
407
+ queue.push([nil, :ok])
408
+ end
409
+ end
410
+ end
411
+ end
412
+
413
+ # Create DBus::Data::Variant with inferred type
414
+ def create_variant(value)
415
+ type = case value
416
+ when TrueClass, FalseClass then DBus::Type::BOOLEAN
417
+ when Integer then DBus::Type::INT32
418
+ when Float then DBus::Type::DOUBLE
419
+ when String then DBus::Type::STRING
420
+ when Array then DBus::Type::Array[DBus::Type::STRING]
421
+ else DBus::Type::VARIANT
422
+ end
423
+
424
+ DBus::Data::Variant.new(value, member_type: type)
425
+ end
426
+
427
+ # Translate D-Bus errors to RBLE errors for connection operations
428
+ def translate_connection_error(error, device_path)
429
+ address = extract_address_from_path(device_path)
430
+
431
+ case error.name
432
+ when 'org.bluez.Error.AlreadyConnected'
433
+ raise AlreadyConnectedError
434
+ when 'org.bluez.Error.NotConnected'
435
+ raise NotConnectedError
436
+ when 'org.bluez.Error.AuthenticationFailed'
437
+ raise AuthenticationError.new(address)
438
+ when 'org.bluez.Error.Failed'
439
+ # Parse message for more context
440
+ if error.message.include?('le-connection-abort') ||
441
+ error.message.include?('Software caused connection abort') ||
442
+ error.message.include?('br-connection-abort')
443
+ raise ConnectionAbortedError.new(address, 'connection aborted by stack')
444
+ elsif error.message.include?('Host is down')
445
+ raise ConnectionFailed.new(address, 'device not reachable (out of range?)')
446
+ else
447
+ raise ConnectionFailed.new(address, error.message)
448
+ end
449
+ when 'org.bluez.Error.NotReady'
450
+ raise AdapterNotReadyError.new
451
+ when 'org.bluez.Error.InProgress'
452
+ raise OperationInProgressError.new("connect to #{address}")
453
+ when 'org.freedesktop.DBus.Error.UnknownObject'
454
+ raise DeviceNotFoundError.new(address)
455
+ else
456
+ raise ConnectionError, "Connection failed: #{error.message} (#{error.name})"
457
+ end
458
+ end
459
+
460
+ # Translate D-Bus errors to RBLE errors for discovery operations
461
+ def translate_discovery_error(error, adapter_path)
462
+ adapter_name = adapter_path.split('/').last
463
+
464
+ case error.name
465
+ when 'org.bluez.Error.NotReady'
466
+ raise AdapterNotReadyError.new(adapter_name)
467
+ when 'org.bluez.Error.Failed'
468
+ raise ScanError, "Discovery failed on #{adapter_name}: #{error.message}"
469
+ when 'org.bluez.Error.InProgress'
470
+ raise ScanInProgressError
471
+ when 'org.bluez.Error.NotAuthorized'
472
+ raise PermissionError, "Discovery on #{adapter_name}"
473
+ when 'org.bluez.Error.NotSupported'
474
+ raise ScanError, "Discovery filter not supported: #{error.message}"
475
+ else
476
+ raise ScanError, "Discovery error: #{error.message} (#{error.name})"
477
+ end
478
+ end
479
+
480
+ # Extract MAC address from device path
481
+ # Path format: /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF
482
+ def extract_address_from_path(path)
483
+ segments = path.split('/')
484
+ dev_segment = segments.find { |s| s.start_with?('dev_') }
485
+ return path unless dev_segment
486
+
487
+ # Convert dev_AA_BB_CC_DD_EE_FF to AA:BB:CC:DD:EE:FF
488
+ dev_segment.sub('dev_', '').tr('_', ':')
489
+ end
490
+ end
491
+ end
492
+ end