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