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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +169 -0
- data/LICENSE.txt +21 -0
- data/README.md +514 -0
- data/exe/rble +14 -0
- data/ext/macos_ble/Package.swift +20 -0
- data/ext/macos_ble/Sources/RBLEHelper/BLEManager.swift +783 -0
- data/ext/macos_ble/Sources/RBLEHelper/Protocol.swift +173 -0
- data/ext/macos_ble/Sources/RBLEHelper/main.swift +645 -0
- data/ext/macos_ble/extconf.rb +73 -0
- data/lib/rble/backend/base.rb +181 -0
- data/lib/rble/backend/bluez.rb +1279 -0
- data/lib/rble/backend/corebluetooth.rb +653 -0
- data/lib/rble/backend.rb +193 -0
- data/lib/rble/bluez/adapter.rb +169 -0
- data/lib/rble/bluez/async_call.rb +85 -0
- data/lib/rble/bluez/async_connection_operations.rb +492 -0
- data/lib/rble/bluez/async_gatt_operations.rb +249 -0
- data/lib/rble/bluez/async_introspection.rb +151 -0
- data/lib/rble/bluez/dbus_connection.rb +64 -0
- data/lib/rble/bluez/dbus_session.rb +344 -0
- data/lib/rble/bluez/device.rb +86 -0
- data/lib/rble/bluez/event_loop.rb +153 -0
- data/lib/rble/bluez/gatt_operation_queue.rb +129 -0
- data/lib/rble/bluez/pairing_agent.rb +132 -0
- data/lib/rble/bluez/pairing_session.rb +212 -0
- data/lib/rble/bluez/retry_policy.rb +55 -0
- data/lib/rble/bluez.rb +33 -0
- data/lib/rble/characteristic.rb +237 -0
- data/lib/rble/cli/adapter.rb +88 -0
- data/lib/rble/cli/characteristic_helpers.rb +154 -0
- data/lib/rble/cli/doctor.rb +309 -0
- data/lib/rble/cli/formatters/json.rb +122 -0
- data/lib/rble/cli/formatters/text.rb +157 -0
- data/lib/rble/cli/hex_dump.rb +48 -0
- data/lib/rble/cli/monitor.rb +129 -0
- data/lib/rble/cli/pair.rb +103 -0
- data/lib/rble/cli/paired.rb +22 -0
- data/lib/rble/cli/read.rb +55 -0
- data/lib/rble/cli/scan.rb +88 -0
- data/lib/rble/cli/show.rb +109 -0
- data/lib/rble/cli/status.rb +25 -0
- data/lib/rble/cli/unpair.rb +39 -0
- data/lib/rble/cli/value_parser.rb +211 -0
- data/lib/rble/cli/write.rb +196 -0
- data/lib/rble/cli.rb +152 -0
- data/lib/rble/company_ids.rb +90 -0
- data/lib/rble/connection.rb +539 -0
- data/lib/rble/device.rb +54 -0
- data/lib/rble/errors.rb +317 -0
- data/lib/rble/gatt/uuid_database.rb +395 -0
- data/lib/rble/scanner.rb +219 -0
- data/lib/rble/service.rb +41 -0
- data/lib/rble/tasks/check.rake +154 -0
- data/lib/rble/tasks/integration.rake +242 -0
- data/lib/rble/tasks.rb +8 -0
- data/lib/rble/version.rb +5 -0
- data/lib/rble.rb +62 -0
- 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
|