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