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,539 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ # Represents a connection to a BLE device
5
+ #
6
+ # Provides access to GATT services and characteristics for reading,
7
+ # writing, and subscribing to values. Includes a state machine for
8
+ # tracking connection lifecycle and callback support for disconnect
9
+ # notifications.
10
+ #
11
+ # @example Connect and read a characteristic
12
+ # connection = RBLE.connect(device.address)
13
+ # connection.discover_services
14
+ # hr_service = connection.service('180d')
15
+ # measurement = hr_service.characteristic('2a37')
16
+ # # Future: value = measurement.read (Plan 04)
17
+ # connection.disconnect
18
+ #
19
+ # @example Register disconnect callback
20
+ # connection = RBLE.connect(device.address)
21
+ # connection.on_disconnect { |reason| puts "Disconnected: #{reason}" }
22
+ # connection.on_state_change { |old_state, new_state| puts "#{old_state} -> #{new_state}" }
23
+ #
24
+ class Connection
25
+ # Valid state transitions for the connection lifecycle
26
+ # @return [Hash{Symbol => Array<Symbol>}]
27
+ VALID_TRANSITIONS = {
28
+ disconnected: [:connecting],
29
+ connecting: [:connected, :disconnected],
30
+ connected: [:disconnecting, :discovering_services, :disconnected],
31
+ discovering_services: [:connected, :disconnected],
32
+ disconnecting: [:disconnected]
33
+ }.freeze
34
+
35
+ attr_reader :address, :device_path, :dbus_session
36
+
37
+ # Create a connection (internal - use RBLE.connect)
38
+ # @param address [String] Device MAC address
39
+ # @param device_path [String] D-Bus device path
40
+ # @param backend [Backend::Base] Platform backend instance
41
+ def initialize(address:, device_path:, backend:)
42
+ @address = address
43
+ @device_path = device_path
44
+ @backend = backend
45
+ @services = nil
46
+ @dbus_session = nil
47
+
48
+ # State machine infrastructure
49
+ @state = :connecting
50
+ @state_mutex = Mutex.new
51
+ @state_callbacks = []
52
+ @disconnect_callbacks = []
53
+
54
+ # Notification thread infrastructure
55
+ @notification_thread = nil
56
+ @notification_mutex = Mutex.new
57
+
58
+ # GATT operation queue for serializing read/write/subscribe operations
59
+ @gatt_queue = nil
60
+
61
+ # Create owned D-Bus session for BlueZ backend
62
+ # Each Connection gets its own D-Bus connection + event loop to avoid
63
+ # state corruption issues when shared connections are used
64
+ setup_dbus_session if bluez_backend?
65
+
66
+ # Transition to connected (connection already established by RBLE.connect)
67
+ warn " [RBLE] Connection.new: transitioning to connected..." if RBLE.trace
68
+ transition_to(:connected)
69
+ warn " [RBLE] Connection.new: done" if RBLE.trace
70
+ end
71
+
72
+ # Get the current connection state
73
+ # @return [Symbol] One of :disconnected, :connecting, :connected, :disconnecting, :discovering_services
74
+ def state
75
+ @state_mutex.synchronize { @state }
76
+ end
77
+
78
+ # Check if still connected
79
+ # @return [Boolean]
80
+ def connected?
81
+ state == :connected
82
+ end
83
+
84
+ # Register a callback for disconnect events
85
+ # @yield [Symbol] Called with disconnect reason when disconnection occurs
86
+ # @yieldparam reason [Symbol] The disconnect reason (:user_requested, :link_loss, :timeout, etc.)
87
+ # @raise [ArgumentError] if no block given
88
+ # @return [void]
89
+ def on_disconnect(&block)
90
+ raise ArgumentError, 'Block required for on_disconnect' unless block_given?
91
+
92
+ @state_mutex.synchronize { @disconnect_callbacks << block }
93
+ end
94
+
95
+ # Register a callback for state change events
96
+ # @yield [Symbol, Symbol] Called with (old_state, new_state) on every state transition
97
+ # @yieldparam old_state [Symbol] The previous state
98
+ # @yieldparam new_state [Symbol] The new state
99
+ # @raise [ArgumentError] if no block given
100
+ # @return [void]
101
+ def on_state_change(&block)
102
+ raise ArgumentError, 'Block required for on_state_change' unless block_given?
103
+
104
+ @state_mutex.synchronize { @state_callbacks << block }
105
+ end
106
+
107
+ # Discover GATT services on the connected device
108
+ # Must be called before accessing services
109
+ #
110
+ # @param timeout [Numeric] Discovery timeout in seconds (default: 30)
111
+ # @return [Array<Service>] Discovered services with ActiveCharacteristic instances
112
+ # @raise [NotConnectedError] if not connected
113
+ # @raise [ServiceDiscoveryError] if discovery fails
114
+ def discover_services(timeout: 30)
115
+ raise NotConnectedError unless connected?
116
+
117
+ transition_to(:discovering_services)
118
+ begin
119
+ # Pass self so backend can use our D-Bus session
120
+ raw_services = @backend.discover_services(@device_path, connection: self, timeout: timeout)
121
+ services = build_services_with_active_characteristics(raw_services)
122
+ @state_mutex.synchronize { @services = services }
123
+ transition_to(:connected)
124
+ services
125
+ rescue StandardError => e
126
+ # On failure, return to connected state (if not already disconnected)
127
+ RBLE.logger&.debug("[RBLE] Service discovery failed: #{e.class}: #{e.message}")
128
+ transition_to(:connected) if state == :discovering_services
129
+ raise
130
+ end
131
+ end
132
+
133
+ # Get all discovered services
134
+ # @return [Array<Service>]
135
+ # @raise [ServiceDiscoveryError] if discover_services not called
136
+ def services
137
+ svcs = @state_mutex.synchronize { @services }
138
+ raise ServiceDiscoveryError, 'Call discover_services first' if svcs.nil?
139
+
140
+ svcs
141
+ end
142
+
143
+ # Get list of currently subscribed characteristic UUIDs
144
+ # Useful for debugging and status checking
145
+ # @return [Array<String>] UUIDs of subscribed characteristics (empty if none)
146
+ def active_subscriptions
147
+ return [] unless @backend.respond_to?(:subscriptions_for_connection)
148
+
149
+ @backend.subscriptions_for_connection(self).filter_map { |sub| sub[:uuid] }
150
+ end
151
+
152
+ # Find a service by UUID
153
+ # Supports both full UUID and short UUID (e.g., "180d")
154
+ #
155
+ # @param uuid [String] Service UUID to find
156
+ # @return [Service]
157
+ # @raise [ServiceNotFoundError] if service not found
158
+ # @raise [ServiceDiscoveryError] if discover_services not called
159
+ def service(uuid)
160
+ normalized = normalize_uuid(uuid)
161
+ found = services.find { |s| s.uuid.downcase == normalized || s.short_uuid == uuid.downcase }
162
+ raise ServiceNotFoundError, uuid unless found
163
+
164
+ found
165
+ end
166
+
167
+ # Disconnect from the device
168
+ # @return [void]
169
+ def disconnect
170
+ current = state
171
+ return if current == :disconnected || current == :disconnecting
172
+
173
+ transition_to(:disconnecting)
174
+ begin
175
+ # Unregister from backend before disconnect to avoid receiving our own disconnect event
176
+ @backend.unregister_connection(@device_path) if @backend.respond_to?(:unregister_connection)
177
+ @backend.disconnect_device(@device_path)
178
+ ensure
179
+ # Destroy D-Bus session (stops event loop, closes connection)
180
+ destroy_dbus_session
181
+ transition_to(:disconnected, reason: :user_requested)
182
+ @state_mutex.synchronize { @services = nil }
183
+ end
184
+ end
185
+
186
+ # Handle disconnection detected by backend
187
+ # @param reason [Symbol] Disconnect reason (:link_loss, :timeout, :remote_disconnect, etc.)
188
+ # @return [Boolean] true if transition happened, false if already disconnected
189
+ def handle_disconnect(reason)
190
+ return false if state == :disconnected
191
+
192
+ # Destroy D-Bus session on unexpected disconnect
193
+ destroy_dbus_session
194
+ transition_to(:disconnected, reason: reason)
195
+ @state_mutex.synchronize { @services = nil }
196
+ true
197
+ end
198
+
199
+ # Process events from the Connection's event loop
200
+ # Call this to receive notifications from subscribed characteristics
201
+ # @param timeout [Numeric, nil] Timeout in seconds (nil = block forever)
202
+ # @yield [Event] Called for each event (optional - handles notifications automatically)
203
+ # @return [Boolean] true if shutdown received, false if timeout
204
+ def process_events(timeout: nil, &block)
205
+ return false unless @dbus_session
206
+
207
+ @dbus_session.process_events(timeout: timeout) do |event|
208
+ handle_connection_event(event)
209
+ block&.call(event)
210
+ end
211
+ end
212
+
213
+ # Drain all pending events without blocking
214
+ # @yield [Event] Called for each event (optional)
215
+ # @return [Integer] Number of events processed
216
+ def drain_events(&block)
217
+ return 0 unless @dbus_session
218
+
219
+ @dbus_session.drain_events do |event|
220
+ handle_connection_event(event)
221
+ block&.call(event)
222
+ end
223
+ end
224
+
225
+ # Ensure notification processing is running
226
+ # Called by backend when first subscription is registered
227
+ # Starts background thread for BlueZ backend
228
+ # @return [void]
229
+ def ensure_notification_processing
230
+ return unless bluez_backend?
231
+
232
+ start_notification_thread
233
+ end
234
+
235
+ # Get or create GATT operation queue for this connection
236
+ # Queue is started on first access and stopped on disconnect
237
+ # @return [RBLE::BlueZ::GattOperationQueue]
238
+ def gatt_queue
239
+ @notification_mutex.synchronize do
240
+ return @gatt_queue if @gatt_queue&.running?
241
+
242
+ @gatt_queue = RBLE::BlueZ::GattOperationQueue.new
243
+ @gatt_queue.start
244
+ @gatt_queue
245
+ end
246
+ end
247
+
248
+ private
249
+
250
+ # Handle events from the Connection's event loop
251
+ # Notifications are dispatched by background thread when running
252
+ # This method is used for manual process_events calls
253
+ # @param event [Event] Event to handle
254
+ def handle_connection_event(event)
255
+ case event.type
256
+ when :notification
257
+ # Background thread dispatches notifications automatically
258
+ # Only dispatch here if no background thread (manual process_events)
259
+ unless @notification_thread&.alive?
260
+ data = event.data
261
+ data[:callback]&.call(data[:value]) if data.is_a?(Hash)
262
+ end
263
+ end
264
+ end
265
+
266
+ # Start background thread for notification processing
267
+ # Thread starts on first subscribe and runs until disconnect
268
+ # @return [void]
269
+ def start_notification_thread
270
+ @notification_mutex.synchronize do
271
+ return if @notification_thread&.alive?
272
+ return unless @dbus_session
273
+
274
+ @notification_thread = Thread.new do
275
+ Thread.current.name = 'rble-notify'
276
+ notification_loop
277
+ end
278
+ end
279
+ end
280
+
281
+ # Main notification processing loop
282
+ # Runs until disconnect, dispatching callbacks with exception handling
283
+ # @return [void]
284
+ def notification_loop
285
+ RBLE.logger&.debug('[RBLE] Notification thread started')
286
+ while connected? && @dbus_session
287
+ begin
288
+ @dbus_session.process_events(timeout: 0.1) do |event|
289
+ dispatch_notification_safely(event) if event.type == :notification
290
+ end
291
+ rescue StandardError => e
292
+ # Log at debug level and continue - don't let errors kill the thread
293
+ RBLE.logger&.debug("[RBLE] Notification thread error: #{e.class}: #{e.message}")
294
+ end
295
+ end
296
+ RBLE.logger&.debug('[RBLE] Notification thread stopped')
297
+ end
298
+
299
+ # Dispatch notification callback with exception protection
300
+ # User callback exceptions are logged but don't kill the thread
301
+ # @param event [Event] Notification event to dispatch
302
+ # @return [void]
303
+ def dispatch_notification_safely(event)
304
+ return unless connected?
305
+
306
+ data = event.data
307
+ return unless data.is_a?(Hash) && data[:callback]
308
+
309
+ RBLE.logger&.debug('[RBLE] Dispatching notification callback')
310
+ begin
311
+ data[:callback].call(data[:value])
312
+ rescue StandardError => e
313
+ RBLE.logger&.debug("[RBLE] Callback raised: #{e.class}: #{e.message}")
314
+ # Continue processing - don't let buggy callbacks break notification delivery
315
+ end
316
+ end
317
+
318
+ # Build Service objects with ActiveCharacteristic instances
319
+ # @param raw_services [Array<Hash>] Service data from backend
320
+ # @return [Array<Service>] Services with active characteristics
321
+ def build_services_with_active_characteristics(raw_services)
322
+ raw_services.map do |service_data|
323
+ characteristics = service_data[:characteristics].map do |char_info|
324
+ ActiveCharacteristic.new(
325
+ characteristic: char_info[:data],
326
+ path: char_info[:path],
327
+ connection: self,
328
+ backend: @backend,
329
+ descriptors: char_info[:descriptors] || []
330
+ )
331
+ end
332
+
333
+ Service.new(
334
+ uuid: service_data[:uuid],
335
+ primary: service_data[:primary],
336
+ characteristics: characteristics
337
+ )
338
+ end
339
+ end
340
+
341
+ # Normalize a short UUID to full 128-bit format
342
+ # @param short_uuid [String] Short or full UUID
343
+ # @return [String] Full 128-bit UUID in lowercase
344
+ def normalize_uuid(short_uuid)
345
+ if short_uuid.length == 4
346
+ "0000#{short_uuid.downcase}-0000-1000-8000-00805f9b34fb"
347
+ else
348
+ short_uuid.downcase
349
+ end
350
+ end
351
+
352
+ # Check if using BlueZ backend
353
+ # @return [Boolean]
354
+ def bluez_backend?
355
+ # Use class name check to avoid requiring BlueZ backend when not needed
356
+ @backend.class.name == 'RBLE::Backend::BlueZ'
357
+ end
358
+
359
+ # Setup D-Bus session for BlueZ backend
360
+ # Creates a new DBusSession, connects, sets up disconnect monitoring, and starts event loop
361
+ # Signal handlers MUST be registered BEFORE starting the event loop to avoid
362
+ # blocking on synchronous AddMatch calls while the loop is running.
363
+ # @return [void]
364
+ # @raise [Error] if session creation fails
365
+ def setup_dbus_session
366
+ warn " [RBLE] Connection: creating DBusSession..." if RBLE.trace
367
+ @dbus_session = RBLE::BlueZ::DBusSession.new
368
+ @dbus_session.connect
369
+
370
+ # Register disconnect signal handler BEFORE starting event loop
371
+ # This avoids the 15-20s blocking that occurs when on_signal is called
372
+ # while the event loop is already running
373
+ warn " [RBLE] Connection: setting up disconnect handler..." if RBLE.trace
374
+ setup_disconnect_signal_handler
375
+
376
+ warn " [RBLE] Connection: starting event loop..." if RBLE.trace
377
+ @dbus_session.start_event_loop
378
+ warn " [RBLE] Connection: setup complete" if RBLE.trace
379
+ rescue StandardError => e
380
+ # Clean up partial session on failure
381
+ @dbus_session&.disconnect
382
+ @dbus_session = nil
383
+ raise Error, "Failed to create D-Bus session: #{e.message}"
384
+ end
385
+
386
+ # Setup PropertiesChanged signal handler for disconnect detection
387
+ # Must be called BEFORE starting the event loop
388
+ # @return [void]
389
+ def setup_disconnect_signal_handler
390
+ return unless @dbus_session
391
+
392
+ # Get device object - use synchronous introspect since event loop not running yet
393
+ warn " [RBLE] Connection: introspecting device..." if RBLE.trace
394
+ start = Time.now
395
+ device_obj = @dbus_session.object(@device_path)
396
+ device_obj.introspect
397
+ warn " [RBLE] Connection: introspect done (#{(Time.now - start).round(2)}s)" if RBLE.trace
398
+ props_iface = device_obj[RBLE::BlueZ::PROPERTIES_INTERFACE]
399
+
400
+ warn " [RBLE] Connection: registering on_signal..." if RBLE.trace
401
+ start = Time.now
402
+ @dbus_session.register_signal_handler(props_iface, 'PropertiesChanged') do |interface, changed, _invalidated|
403
+ next unless interface == RBLE::BlueZ::DEVICE_INTERFACE
404
+ next unless changed.key?('Connected')
405
+
406
+ if changed['Connected'] == false
407
+ # Device disconnected - notify via backend
408
+ @backend.send(:handle_unexpected_disconnect, @device_path) if @backend.respond_to?(:handle_unexpected_disconnect, true)
409
+ end
410
+ end
411
+ warn " [RBLE] Connection: on_signal done (#{(Time.now - start).round(2)}s)" if RBLE.trace
412
+ end
413
+
414
+ # Destroy D-Bus session
415
+ # Stops GATT queue, notification thread, event loop and closes connection
416
+ # @return [void]
417
+ def destroy_dbus_session
418
+ return unless @dbus_session
419
+
420
+ # Stop GATT operation queue first
421
+ @gatt_queue&.stop
422
+ @gatt_queue = nil
423
+
424
+ # Stop notification thread
425
+ @notification_mutex.synchronize do
426
+ if @notification_thread&.alive?
427
+ # Thread will exit naturally when it sees we're disconnected
428
+ # Give it a short timeout to exit gracefully
429
+ @notification_thread.join(1)
430
+ @notification_thread.kill if @notification_thread.alive?
431
+ end
432
+ @notification_thread = nil
433
+ end
434
+
435
+ begin
436
+ @dbus_session.disconnect
437
+ rescue StandardError => e
438
+ # Log warning but continue - fire and forget
439
+ warn "[RBLE] Failed to disconnect D-Bus session: #{e.message}"
440
+ ensure
441
+ @dbus_session = nil
442
+ end
443
+ end
444
+
445
+ # Transition to a new state, calling callbacks outside the mutex
446
+ # @param new_state [Symbol] The state to transition to
447
+ # @param reason [Symbol, nil] Disconnect reason (only for :disconnected state)
448
+ # @return [Boolean] true if transition succeeded, false if invalid
449
+ def transition_to(new_state, reason: nil)
450
+ old_state = nil
451
+ state_callbacks_copy = []
452
+ disconnect_callbacks_copy = []
453
+
454
+ @state_mutex.synchronize do
455
+ return false unless VALID_TRANSITIONS[@state]&.include?(new_state)
456
+
457
+ old_state = @state
458
+ @state = new_state
459
+
460
+ # Copy callbacks while holding lock
461
+ state_callbacks_copy = @state_callbacks.dup
462
+ disconnect_callbacks_copy = @disconnect_callbacks.dup if new_state == :disconnected
463
+ end
464
+
465
+ # Call callbacks outside mutex to prevent deadlocks
466
+ state_callbacks_copy.each do |callback|
467
+ begin
468
+ callback.call(old_state, new_state)
469
+ rescue StandardError => e
470
+ warn "[RBLE] State change callback error: #{e.message}"
471
+ end
472
+ end
473
+
474
+ # Call disconnect callbacks if transitioning to disconnected
475
+ if new_state == :disconnected
476
+ disconnect_callbacks_copy.each do |callback|
477
+ begin
478
+ callback.call(reason)
479
+ rescue StandardError => e
480
+ warn "[RBLE] Disconnect callback error: #{e.message}"
481
+ end
482
+ end
483
+ end
484
+
485
+ true
486
+ end
487
+ end
488
+
489
+ class << self
490
+ # Connect to a BLE device by address
491
+ #
492
+ # @param address [String] Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
493
+ # @param timeout [Numeric] Connection timeout in seconds (default: 30)
494
+ # @param adapter [String, nil] Bluetooth adapter name (e.g., "hci0")
495
+ # @param device_name [String, nil] Device name for error messages (from scan)
496
+ # @return [Connection] Connected device handle
497
+ # @raise [ConnectionTimeoutError] if connection times out
498
+ # @raise [DeviceNotFoundError] if device not found or disappeared
499
+ # @raise [ConnectionError] if connection fails
500
+ #
501
+ # @example
502
+ # conn = RBLE.connect("AA:BB:CC:DD:EE:FF", timeout: 10)
503
+ # conn.discover_services
504
+ # # ... use services ...
505
+ # conn.disconnect
506
+ #
507
+ def connect(address, timeout: 30, adapter: nil, device_name: nil)
508
+ backend = Backend.for_platform
509
+
510
+ # Convert address to D-Bus path
511
+ device_path = backend.device_path_for_address(address, adapter: adapter)
512
+ raise DeviceNotFoundError.new(address, device_name: device_name) unless device_path
513
+
514
+ # Connect via backend
515
+ begin
516
+ backend.connect_device(device_path, timeout: timeout)
517
+ rescue DeviceNotFoundError => e
518
+ # Re-raise with device name if we have it and error doesn't already include it
519
+ raise DeviceNotFoundError.new(e.address, device_name: device_name) if device_name && !e.device_name
520
+ raise
521
+ end
522
+
523
+ warn " [RBLE] RBLE.connect: creating Connection..." if RBLE.trace
524
+ connection = Connection.new(
525
+ address: address,
526
+ device_path: device_path,
527
+ backend: backend
528
+ )
529
+ warn " [RBLE] RBLE.connect: Connection created" if RBLE.trace
530
+
531
+ # Register connection for disconnect monitoring (BlueZ backend)
532
+ warn " [RBLE] RBLE.connect: registering connection..." if RBLE.trace
533
+ backend.register_connection(device_path, connection) if backend.respond_to?(:register_connection)
534
+ warn " [RBLE] RBLE.connect: done" if RBLE.trace
535
+
536
+ connection
537
+ end
538
+ end
539
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ # Immutable snapshot of a discovered BLE device
5
+ #
6
+ # @!attribute address [String] MAC address (uppercase, colon-separated: "12:34:56:78:9A:BC")
7
+ # @!attribute name [String, nil] Device local name from advertisement
8
+ # @!attribute rssi [Integer, nil] Signal strength in dBm
9
+ # @!attribute manufacturer_data [Hash{Integer => Array<Integer>}] Company ID => byte array
10
+ # @!attribute manufacturer_data_raw [Hash{Integer => String}] Company ID => binary string
11
+ # @!attribute service_data [Hash{String => Array<Integer>}] UUID => byte array
12
+ # @!attribute service_uuids [Array<String>] Advertised service UUIDs
13
+ # @!attribute tx_power [Integer, nil] Transmit power level in dBm
14
+ # @!attribute address_type [String] "public" or "random"
15
+ Device = Data.define(
16
+ :address,
17
+ :name,
18
+ :rssi,
19
+ :manufacturer_data,
20
+ :manufacturer_data_raw,
21
+ :service_data,
22
+ :service_uuids,
23
+ :tx_power,
24
+ :address_type
25
+ ) do
26
+ def initialize(
27
+ address:,
28
+ name: nil,
29
+ rssi: nil,
30
+ manufacturer_data: {},
31
+ manufacturer_data_raw: {},
32
+ service_data: {},
33
+ service_uuids: [],
34
+ tx_power: nil,
35
+ address_type: 'public'
36
+ )
37
+ super
38
+ end
39
+
40
+ # Create a new Device with updated attributes
41
+ # @param attrs [Hash] attributes to update
42
+ # @return [Device] new Device instance with updates
43
+ def update(**attrs)
44
+ with(**attrs)
45
+ end
46
+
47
+ # Get manufacturer data as a binary string for a given company ID
48
+ # @param company_id [Integer] BLE company identifier (e.g., 0x0499 for Ruuvi)
49
+ # @return [String, nil] Binary string (ASCII-8BIT) or nil if not present
50
+ def manufacturer_data_bytes(company_id)
51
+ manufacturer_data_raw[company_id]
52
+ end
53
+ end
54
+ end