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,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'async_call'
4
+ require_relative 'async_introspection'
5
+ require_relative 'async_gatt_operations'
6
+ require_relative 'async_connection_operations'
7
+
8
+ module RBLE
9
+ module BlueZ
10
+ # Encapsulates a D-Bus connection and event loop lifecycle for a single BLE session.
11
+ #
12
+ # Each DBusSession provides an isolated D-Bus communication channel with its own
13
+ # event loop. This design eliminates state corruption issues that occur when a
14
+ # shared D-Bus connection is used across multiple operations (scan, connect, notify).
15
+ #
16
+ # Key design decisions:
17
+ # - Composition over inheritance: DBusSession composes DBusConnection + EventLoop
18
+ # - Event loop uses the session's connection.bus
19
+ # - Session tracks its own lifecycle (connected/running state)
20
+ # - Thread::Queue is owned by EventLoop (already implemented there)
21
+ #
22
+ # @example Basic usage
23
+ # session = RBLE::BlueZ::DBusSession.new
24
+ # session.connect
25
+ # session.start_event_loop
26
+ # # ... use session.bus for D-Bus operations ...
27
+ # session.disconnect # stops event loop and closes connection
28
+ #
29
+ class DBusSession
30
+ include AsyncCall
31
+ include AsyncIntrospection
32
+ include AsyncGattOperations
33
+ include AsyncConnectionOperations
34
+
35
+ # Create a new DBusSession (not yet connected)
36
+ def initialize
37
+ @connection = nil
38
+ @event_loop = nil
39
+ @mutex = Mutex.new
40
+ @introspection_cache = {}
41
+ @registered_handlers = [] # Array of [proxy_iface, signal_name] tuples
42
+ @pending_queues = [] # Array of Thread::Queue for pending async calls
43
+ @closed = false # Track closed state for idempotent close
44
+ end
45
+
46
+ # Get the D-Bus service
47
+ # Required by AsyncIntrospection module
48
+ # @return [DBus::Service, nil]
49
+ def service
50
+ @mutex.synchronize { @connection&.service }
51
+ end
52
+
53
+ # Register a signal handler with tracking for cleanup
54
+ # @param proxy_iface [DBus::ProxyObjectInterface] The interface
55
+ # @param signal_name [String] Signal name (e.g., 'PropertiesChanged')
56
+ # @yield Signal handler block
57
+ # @return [void]
58
+ def register_signal_handler(proxy_iface, signal_name, &block)
59
+ proxy_iface.on_signal(signal_name, &block)
60
+ @registered_handlers << [proxy_iface, signal_name]
61
+ end
62
+
63
+ # Check if session is closed
64
+ # @return [Boolean]
65
+ def closed?
66
+ @mutex.synchronize { @closed }
67
+ end
68
+
69
+ # Connect to the D-Bus system bus
70
+ # Creates a new DBusConnection and establishes connection to BlueZ
71
+ # @return [void]
72
+ # @raise [PermissionError] if permission denied
73
+ # @raise [Error] if BlueZ service not available
74
+ def connect
75
+ @mutex.synchronize do
76
+ return if @connection&.connected?
77
+
78
+ @connection = DBusConnection.new
79
+ @connection.connect
80
+ end
81
+ end
82
+
83
+ # Start the event loop in a background thread
84
+ # The event loop processes D-Bus signals and enqueues events for the main thread
85
+ # @return [void]
86
+ # @raise [Error] if not connected
87
+ def start_event_loop
88
+ @mutex.synchronize do
89
+ raise Error, 'Cannot start event loop: not connected' unless @connection&.connected?
90
+ return if @event_loop&.running?
91
+
92
+ # Register handlers BEFORE starting event loop — avoids synchronous
93
+ # AddMatch deadlock with the event loop reading the same socket.
94
+ setup_cache_invalidation_handler
95
+
96
+ @event_loop = EventLoop.new
97
+ @event_loop.start(@connection.bus)
98
+ end
99
+ end
100
+
101
+ # Stop the event loop
102
+ # Waits for the background thread to finish
103
+ # @param timeout [Numeric] Maximum seconds to wait for thread
104
+ # @return [void]
105
+ def stop_event_loop(timeout: 1)
106
+ @mutex.synchronize do
107
+ @event_loop&.stop(timeout: timeout)
108
+ end
109
+ end
110
+
111
+ # Close the session (idempotent)
112
+ # Stops event loop, unregisters signal handlers, closes connection
113
+ # Safe teardown order: notify pending -> stop loop -> unregister handlers -> close connection
114
+ # @return [void]
115
+ def close
116
+ @mutex.synchronize do
117
+ return if @closed
118
+ @closed = true
119
+
120
+ # Notify pending async callers they won't get results
121
+ @pending_queues.each { |q| q.push([:session_closed, nil]) }
122
+ @pending_queues.clear
123
+
124
+ # Stop event loop FIRST (critical - prevents RemoveMatch deadlock)
125
+ @event_loop&.stop(timeout: 1)
126
+ @event_loop = nil
127
+
128
+ # NOW safe to unregister signal handlers (no Main loop = no deadlock)
129
+ unregister_signal_handlers
130
+
131
+ # Close D-Bus connection last
132
+ @connection&.disconnect
133
+ @connection = nil
134
+
135
+ RBLE.logger&.debug('[RBLE] Session closed')
136
+ end
137
+ end
138
+
139
+ # Backward compatibility alias
140
+ alias disconnect close
141
+
142
+ # Check if connected to D-Bus
143
+ # @return [Boolean]
144
+ def connected?
145
+ @mutex.synchronize { !@closed && (@connection&.connected? || false) }
146
+ end
147
+
148
+ # Check if event loop is running
149
+ # @return [Boolean]
150
+ def running?
151
+ @mutex.synchronize { @event_loop&.running? || false }
152
+ end
153
+
154
+ # Get the event loop's raw queue for signal-safe direct push
155
+ # Returns nil if no event loop is active
156
+ # Reads @event_loop directly (no mutex) which is safe for reading a single
157
+ # instance variable - used from signal trap context where mutex is unsafe
158
+ # @return [Thread::Queue, nil]
159
+ def event_loop_queue
160
+ @event_loop&.queue
161
+ end
162
+
163
+ # Get the D-Bus bus for signal registration
164
+ # @return [DBus::SystemBus, nil]
165
+ def bus
166
+ @mutex.synchronize { @connection&.bus }
167
+ end
168
+
169
+ # Get a D-Bus object by path
170
+ # @param path [String] D-Bus object path
171
+ # @return [DBus::ProxyObject]
172
+ # @raise [Error] if not connected
173
+ def object(path)
174
+ conn = @mutex.synchronize { @connection }
175
+ raise Error, 'Not connected' unless conn&.connected?
176
+
177
+ conn.object(path)
178
+ end
179
+
180
+ # Get the ObjectManager interface
181
+ # @return [DBus::ProxyObjectInterface]
182
+ # @raise [Error] if not connected
183
+ def object_manager
184
+ conn = @mutex.synchronize { @connection }
185
+ raise Error, 'Not connected' unless conn&.connected?
186
+
187
+ conn.object_manager
188
+ end
189
+
190
+ # Enqueue an event for processing
191
+ # Thread-safe - can be called from D-Bus signal handlers
192
+ # @param type [Symbol] Event type (:device_found, :notification, etc.)
193
+ # @param path [String, nil] D-Bus object path
194
+ # @param data [Hash, nil] Event-specific data
195
+ # @return [void]
196
+ def enqueue(type, path, data)
197
+ event_loop = @mutex.synchronize { @event_loop }
198
+ event_loop&.enqueue(type, path, data)
199
+ end
200
+
201
+ # Process events from the queue with a timeout
202
+ # Yields each event to the block until shutdown or timeout
203
+ # @param timeout [Numeric, nil] Timeout in seconds (nil = block forever)
204
+ # @yield [Event] Called for each event
205
+ # @return [Boolean] true if shutdown received, false if timeout
206
+ def process_events(timeout: nil, &block)
207
+ event_loop = @mutex.synchronize { @event_loop }
208
+ return false unless event_loop
209
+
210
+ event_loop.process_events(timeout: timeout, &block)
211
+ end
212
+
213
+ # Non-blocking drain of all pending events
214
+ # @yield [Event] Called for each event
215
+ # @return [Integer] Number of events processed
216
+ def drain_events(&block)
217
+ event_loop = @mutex.synchronize { @event_loop }
218
+ return 0 unless event_loop
219
+
220
+ event_loop.drain_events(&block)
221
+ end
222
+
223
+ # Register a signal handler asynchronously (safe while event loop is running).
224
+ #
225
+ # Splits on_signal into two steps:
226
+ # 1. Local handler registration (immediate, no D-Bus I/O)
227
+ # 2. D-Bus AddMatch message (async via event loop)
228
+ #
229
+ # @param proxy_iface [DBus::ProxyObjectInterface] The interface to watch
230
+ # @param signal_name [String] Signal name (e.g., 'PropertiesChanged')
231
+ # @param timeout [Numeric] Timeout for AddMatch acknowledgement
232
+ # @yield [*params] Called when matching signal arrives
233
+ # @return [void]
234
+ def async_register_signal_handler(proxy_iface, signal_name, timeout: 5, &block)
235
+ bus = proxy_iface.object.bus
236
+ mr = DBus::MatchRule.new.from_signal(proxy_iface, signal_name)
237
+ mrs = mr.to_s
238
+
239
+ # Step 1: Register handler locally (no D-Bus I/O)
240
+ # Call Connection#add_match directly, bypassing BusConnection's synchronous override
241
+ DBus::Connection.instance_method(:add_match).bind_call(bus, mrs) do |msg|
242
+ block.call(*msg.params)
243
+ end
244
+
245
+ # Step 2: Send AddMatch to D-Bus daemon asynchronously
246
+ async_call("AddMatch(#{signal_name})", timeout: timeout) do |queue, _request_id, cancelled|
247
+ msg = DBus::Message.new(DBus::Message::METHOD_CALL)
248
+ msg.path = '/org/freedesktop/DBus'
249
+ msg.interface = 'org.freedesktop.DBus'
250
+ msg.destination = 'org.freedesktop.DBus'
251
+ msg.member = 'AddMatch'
252
+ msg.sender = bus.unique_name
253
+ msg.add_param('s', mrs)
254
+
255
+ bus.send_sync_or_async(msg) do |reply|
256
+ next if cancelled[0]
257
+ if reply.is_a?(DBus::Error)
258
+ queue.push([reply, nil])
259
+ else
260
+ queue.push([nil, :ok])
261
+ end
262
+ end
263
+ end
264
+
265
+ @registered_handlers << [proxy_iface, signal_name]
266
+ end
267
+
268
+ # Unregister a signal handler asynchronously (safe while event loop is running).
269
+ #
270
+ # @param proxy_iface [DBus::ProxyObjectInterface] The interface
271
+ # @param signal_name [String] Signal name
272
+ # @param timeout [Numeric] Timeout for RemoveMatch acknowledgement
273
+ # @return [void]
274
+ def async_unregister_signal_handler(proxy_iface, signal_name, timeout: 5)
275
+ bus = proxy_iface.object.bus
276
+ mr = DBus::MatchRule.new.from_signal(proxy_iface, signal_name)
277
+ mrs = mr.to_s
278
+
279
+ # Step 1: Remove handler locally (no D-Bus I/O)
280
+ DBus::Connection.instance_method(:remove_match).bind_call(bus, mrs)
281
+
282
+ # Step 2: Send RemoveMatch to D-Bus daemon asynchronously
283
+ async_call("RemoveMatch(#{signal_name})", timeout: timeout) do |queue, _request_id, cancelled|
284
+ msg = DBus::Message.new(DBus::Message::METHOD_CALL)
285
+ msg.path = '/org/freedesktop/DBus'
286
+ msg.interface = 'org.freedesktop.DBus'
287
+ msg.destination = 'org.freedesktop.DBus'
288
+ msg.member = 'RemoveMatch'
289
+ msg.sender = bus.unique_name
290
+ msg.add_param('s', mrs)
291
+
292
+ bus.send_sync_or_async(msg) do |reply|
293
+ next if cancelled[0]
294
+ if reply.is_a?(DBus::Error)
295
+ queue.push([reply, nil])
296
+ else
297
+ queue.push([nil, :ok])
298
+ end
299
+ end
300
+ end
301
+
302
+ @registered_handlers.delete_if { |pi, sn| pi == proxy_iface && sn == signal_name }
303
+ rescue StandardError => e
304
+ RBLE.logger&.debug("[RBLE] async_unregister_signal_handler error: #{e.message}")
305
+ end
306
+
307
+ private
308
+
309
+ # Setup handler to clear introspection cache when devices are removed
310
+ # Called when event loop starts to ensure stale introspection data is cleared
311
+ # when BlueZ removes devices
312
+ # Must be called within @mutex.synchronize
313
+ def setup_cache_invalidation_handler
314
+ return unless @connection&.connected?
315
+
316
+ root = @connection.root_object
317
+ om = root[OBJECT_MANAGER_INTERFACE]
318
+
319
+ register_signal_handler(om, 'InterfacesRemoved') do |path, _interfaces|
320
+ # Clear the removed path
321
+ clear_introspection(path)
322
+
323
+ # Clear all child paths (e.g., /dev_XX/service0001 when /dev_XX removed)
324
+ @introspection_cache.keys.each do |cached_path|
325
+ clear_introspection(cached_path) if cached_path.start_with?("#{path}/")
326
+ end
327
+ end
328
+ end
329
+
330
+ # Unregister all tracked signal handlers
331
+ # Must be called AFTER event loop is stopped to avoid RemoveMatch deadlock
332
+ def unregister_signal_handlers
333
+ @registered_handlers.each do |proxy_iface, signal_name|
334
+ begin
335
+ proxy_iface.on_signal(signal_name) # No block = unregister
336
+ rescue StandardError => e
337
+ RBLE.logger&.debug("[RBLE] Signal cleanup ignored: #{e.message}")
338
+ end
339
+ end
340
+ @registered_handlers.clear
341
+ end
342
+ end
343
+ end
344
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ module BlueZ
5
+ # Wrapper for BlueZ Device1 D-Bus interface
6
+ # Provides connection lifecycle management
7
+ class Device
8
+ attr_reader :path, :address
9
+
10
+ # Create a Device from a DBusSession
11
+ # @param session [DBusSession] Active D-Bus session
12
+ # @param device_path [String] D-Bus object path (e.g., /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX)
13
+ # @return [Device]
14
+ def self.new_from_session(session, device_path)
15
+ device = allocate
16
+ device.instance_variable_set(:@session, session)
17
+ device.instance_variable_set(:@path, device_path)
18
+ device.instance_variable_set(:@address, device.send(:extract_address_from_path, device_path))
19
+ # Async introspect for non-blocking setup
20
+ proxy = session.async_introspect(device_path, timeout: 5)
21
+ device.instance_variable_set(:@object, proxy)
22
+ device.instance_variable_set(:@device_iface, proxy[DEVICE_INTERFACE])
23
+ device.instance_variable_set(:@props_iface, proxy[PROPERTIES_INTERFACE])
24
+ device
25
+ end
26
+
27
+ # Check if device is currently connected
28
+ # @return [Boolean] true if connected
29
+ def connected?
30
+ @session.async_get_property(@path, DEVICE_INTERFACE, 'Connected', timeout: 5)
31
+ end
32
+
33
+ # Check if GATT services have been resolved
34
+ # @return [Boolean] true if services are resolved
35
+ def services_resolved?
36
+ @session.async_get_property(@path, DEVICE_INTERFACE, 'ServicesResolved', timeout: 5)
37
+ end
38
+
39
+ # Get device name from adapter cache
40
+ # @return [String, nil] device name or nil if not known
41
+ def name
42
+ @session.async_get_property(@path, DEVICE_INTERFACE, 'Name', timeout: 5)
43
+ rescue DBus::Error
44
+ nil
45
+ end
46
+
47
+ # Connect to device
48
+ # Waits for connection and optionally for services to resolve
49
+ # @param wait_for_services [Boolean] Wait for GATT services to be discovered (default: true)
50
+ # @param timeout [Numeric] Connection timeout in seconds (default: 30)
51
+ # @return [Boolean] true on success
52
+ # @raise [ConnectionError] if connection fails
53
+ # @raise [TimeoutError] if connection times out
54
+ def connect(wait_for_services: true, timeout: 30)
55
+ # Async path: handles waiting for connection and services
56
+ @session.async_connect(@path, wait_for_services: wait_for_services, timeout: timeout)
57
+ rescue DBus::Error => e
58
+ raise ConnectionError, "Failed to initiate connection: #{e.message}"
59
+ end
60
+
61
+ # Disconnect from device
62
+ # @param timeout [Numeric] Timeout in seconds (default: 5)
63
+ # @return [Boolean] true on success
64
+ # @raise [ConnectionError] if disconnect fails
65
+ def disconnect(timeout: 5)
66
+ # Async path: idempotent disconnect
67
+ @session.async_disconnect(@path, timeout: timeout)
68
+ rescue DBus::Error => e
69
+ raise ConnectionError, "Failed to disconnect: #{e.message}"
70
+ end
71
+
72
+ private
73
+
74
+ # Extract MAC address from D-Bus object path
75
+ # @param path [String] e.g., /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF
76
+ # @return [String] MAC address (e.g., AA:BB:CC:DD:EE:FF) or 'UNKNOWN'
77
+ def extract_address_from_path(path)
78
+ 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
79
+ Regexp.last_match(1).tr('_', ':').upcase
80
+ else
81
+ 'UNKNOWN'
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ module BlueZ
5
+ # Event types for queue communication
6
+ Event = Data.define(:type, :path, :data) do
7
+ def initialize(type:, path: nil, data: nil)
8
+ super
9
+ end
10
+ end
11
+
12
+ # Runs D-Bus main loop in background thread, marshals events via Queue
13
+ class EventLoop
14
+ attr_reader :queue
15
+
16
+ def initialize
17
+ @queue = Thread::Queue.new
18
+ @main_loop = nil
19
+ @thread = nil
20
+ @running = false
21
+ @mutex = Mutex.new
22
+ end
23
+
24
+ # Start the event loop in a background thread
25
+ # @param bus [DBus::SystemBus] The D-Bus bus to run
26
+ def start(bus)
27
+ @mutex.synchronize do
28
+ return if @running
29
+
30
+ @running = true
31
+ end
32
+
33
+ @main_loop = DBus::Main.new
34
+ @main_loop << bus
35
+
36
+ @thread = Thread.new do
37
+ Thread.current.name = 'rble-dbus-loop'
38
+ begin
39
+ # DBus::Main.run blocks until quit is called
40
+ # Signal handlers registered on the bus will be dispatched
41
+ @main_loop.run
42
+ rescue StandardError => e
43
+ enqueue(:error, nil, { exception: e })
44
+ ensure
45
+ @mutex.synchronize { @running = false }
46
+ end
47
+ end
48
+
49
+ # Give the thread a moment to start
50
+ sleep(0.05)
51
+ end
52
+
53
+ # Stop the event loop and wait for thread to finish
54
+ # @param timeout [Numeric] Maximum seconds to wait for thread
55
+ def stop(timeout: 1)
56
+ was_running = @mutex.synchronize do
57
+ return unless @running
58
+
59
+ @running = false
60
+ true
61
+ end
62
+
63
+ return unless was_running
64
+
65
+ # Tell DBus::Main to exit its run loop
66
+ @main_loop&.quit
67
+
68
+ # Signal any blocked queue readers
69
+ enqueue(:shutdown, nil, nil)
70
+
71
+ # Skip join if called from within the event loop thread itself
72
+ # (e.g., during disconnect callback handling)
73
+ if @thread&.alive? && @thread != Thread.current
74
+ @thread.join(timeout)
75
+ @thread.kill if @thread.alive? # Force kill if still running
76
+ end
77
+
78
+ @thread = nil
79
+ @main_loop = nil
80
+
81
+ # Drain the queue
82
+ @queue.clear
83
+ end
84
+
85
+ # Check if event loop is running
86
+ # @return [Boolean]
87
+ def running?
88
+ @mutex.synchronize { @running }
89
+ end
90
+
91
+ # Enqueue an event (called from D-Bus signal handlers)
92
+ # @param type [Symbol] Event type (:device_found, :device_removed, :properties_changed, :error, :shutdown)
93
+ # @param path [String, nil] D-Bus object path
94
+ # @param data [Hash, nil] Event-specific data
95
+ def enqueue(type, path, data)
96
+ @queue.push(Event.new(type: type, path: path, data: data))
97
+ end
98
+
99
+ # Process events from the queue with a timeout
100
+ # Yields each event to the block until shutdown or timeout
101
+ # @param timeout [Numeric, nil] Timeout in seconds (nil = block forever)
102
+ # @yield [Event] Called for each event
103
+ # @return [Boolean] true if shutdown received, false if timeout
104
+ def process_events(timeout: nil, &block)
105
+ deadline = timeout ? Time.now + timeout : nil
106
+
107
+ loop do
108
+ # Check deadline before waiting
109
+ if deadline
110
+ remaining = deadline - Time.now
111
+ return false if remaining <= 0
112
+ else
113
+ remaining = nil
114
+ end
115
+
116
+ begin
117
+ # pop with timeout returns nil on timeout (only in Ruby 3.2+)
118
+ event = if remaining
119
+ @queue.pop(timeout: remaining)
120
+ else
121
+ @queue.pop
122
+ end
123
+ rescue ThreadError
124
+ # Queue closed
125
+ return true
126
+ end
127
+
128
+ return false if event.nil? # Timeout
129
+ return true if event.type == :shutdown
130
+
131
+ yield event if block_given?
132
+ end
133
+ end
134
+
135
+ # Non-blocking drain of all pending events
136
+ # @yield [Event] Called for each event
137
+ # @return [Integer] Number of events processed
138
+ def drain_events(&block)
139
+ count = 0
140
+ while (event = @queue.pop(true))
141
+ break if event.nil? || event.type == :shutdown
142
+
143
+ yield event if block_given?
144
+ count += 1
145
+ end
146
+ count
147
+ rescue ThreadError
148
+ # Queue empty - expected when draining
149
+ count
150
+ end
151
+ end
152
+ end
153
+ end