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,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dbus'
|
|
4
|
+
require_relative 'async_call'
|
|
5
|
+
require_relative 'async_introspection'
|
|
6
|
+
require_relative 'retry_policy'
|
|
7
|
+
|
|
8
|
+
module RBLE
|
|
9
|
+
module BlueZ
|
|
10
|
+
# Provides async GATT operations using AsyncCall + AsyncIntrospection patterns.
|
|
11
|
+
# Use this to perform GATT read/write/notify operations without blocking the event loop.
|
|
12
|
+
#
|
|
13
|
+
# This module is part of the v0.4.0 async architecture. It builds on AsyncCall
|
|
14
|
+
# and AsyncIntrospection to provide non-blocking GATT operations.
|
|
15
|
+
#
|
|
16
|
+
# Including class must:
|
|
17
|
+
# - Include AsyncCall module (provides async_call method)
|
|
18
|
+
# - Include AsyncIntrospection module (provides async_introspect method)
|
|
19
|
+
# - Have @service attribute pointing to D-Bus service
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# include AsyncCall
|
|
23
|
+
# include AsyncIntrospection
|
|
24
|
+
# include AsyncGattOperations
|
|
25
|
+
#
|
|
26
|
+
# value = async_read_value("/org/bluez/hci0/.../char0011")
|
|
27
|
+
# async_write_value("/org/bluez/hci0/.../char0011", [0x01, 0x02])
|
|
28
|
+
# async_start_notify("/org/bluez/hci0/.../char0011")
|
|
29
|
+
# async_stop_notify("/org/bluez/hci0/.../char0011")
|
|
30
|
+
#
|
|
31
|
+
module AsyncGattOperations
|
|
32
|
+
include RetryPolicy
|
|
33
|
+
GATT_CHARACTERISTIC_INTERFACE = 'org.bluez.GattCharacteristic1'
|
|
34
|
+
PROPERTIES_INTERFACE = 'org.freedesktop.DBus.Properties'
|
|
35
|
+
|
|
36
|
+
# Default timeouts (Claude's discretion per CONTEXT.md)
|
|
37
|
+
DEFAULT_READ_TIMEOUT = 5
|
|
38
|
+
DEFAULT_WRITE_TIMEOUT = 5
|
|
39
|
+
DEFAULT_NOTIFY_TIMEOUT = 5
|
|
40
|
+
WRITE_NO_RESPONSE_TIMEOUT = 1
|
|
41
|
+
|
|
42
|
+
# Async read characteristic value
|
|
43
|
+
#
|
|
44
|
+
# @param char_path [String] D-Bus characteristic path
|
|
45
|
+
# @param timeout [Numeric] Total timeout in seconds for the entire read operation (default: 5)
|
|
46
|
+
# @return [String] Binary string (ASCII-8BIT encoding)
|
|
47
|
+
# @raise [TimeoutError] if read times out
|
|
48
|
+
# @raise [ReadError] if read fails
|
|
49
|
+
# @raise [NotConnectedError] if device disconnected
|
|
50
|
+
# @raise [OperationInProgressError] if retries exhausted
|
|
51
|
+
def async_read_value(char_path, timeout: DEFAULT_READ_TIMEOUT)
|
|
52
|
+
uuid = extract_uuid_from_path(char_path)
|
|
53
|
+
deadline = Time.now + timeout
|
|
54
|
+
|
|
55
|
+
proxy = async_introspect(char_path, timeout: gatt_remaining_timeout(deadline, timeout, 'ReadValue'))
|
|
56
|
+
char_iface = proxy[GATT_CHARACTERISTIC_INTERFACE]
|
|
57
|
+
|
|
58
|
+
result = with_inprogress_retry do
|
|
59
|
+
async_call("ReadValue(#{uuid})", timeout: gatt_remaining_timeout(deadline, timeout, 'ReadValue')) do |queue, _request_id, cancelled|
|
|
60
|
+
char_iface.ReadValue({}) do |reply|
|
|
61
|
+
next if cancelled[0] # Discard late callback
|
|
62
|
+
if reply.is_a?(DBus::Error)
|
|
63
|
+
queue.push([reply, nil])
|
|
64
|
+
else
|
|
65
|
+
# Extract params from DBus::Message - ReadValue returns [Array<Byte>]
|
|
66
|
+
params = reply.respond_to?(:params) ? reply.params : [reply]
|
|
67
|
+
queue.push([nil, params.first])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Convert D-Bus byte array to binary string
|
|
74
|
+
bytes = result || []
|
|
75
|
+
bytes.map(&:to_i).pack('C*')
|
|
76
|
+
rescue DBus::Error => e
|
|
77
|
+
translate_gatt_error(e, char_path, 'Read')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Async write characteristic value
|
|
81
|
+
#
|
|
82
|
+
# @param char_path [String] D-Bus characteristic path
|
|
83
|
+
# @param bytes [Array<Integer>] Byte array to write
|
|
84
|
+
# @param response [Boolean] Wait for write response (default: true)
|
|
85
|
+
# @param timeout [Numeric] Total timeout in seconds for the entire write operation (default: 5)
|
|
86
|
+
# @return [Boolean] true on success
|
|
87
|
+
# @raise [TimeoutError] if write times out
|
|
88
|
+
# @raise [WriteError] if write fails
|
|
89
|
+
# @raise [NotConnectedError] if device disconnected
|
|
90
|
+
# @raise [OperationInProgressError] if retries exhausted
|
|
91
|
+
def async_write_value(char_path, bytes, response: true, timeout: DEFAULT_WRITE_TIMEOUT)
|
|
92
|
+
uuid = extract_uuid_from_path(char_path)
|
|
93
|
+
|
|
94
|
+
# Write-without-response uses minimal timeout (CONTEXT.md decision)
|
|
95
|
+
effective_timeout = response ? timeout : WRITE_NO_RESPONSE_TIMEOUT
|
|
96
|
+
deadline = Time.now + effective_timeout
|
|
97
|
+
|
|
98
|
+
proxy = async_introspect(char_path, timeout: gatt_remaining_timeout(deadline, effective_timeout, 'WriteValue'))
|
|
99
|
+
char_iface = proxy[GATT_CHARACTERISTIC_INTERFACE]
|
|
100
|
+
|
|
101
|
+
# Build write options
|
|
102
|
+
type_value = response ? 'request' : 'command'
|
|
103
|
+
options = {
|
|
104
|
+
'type' => DBus::Data::Variant.new(type_value, member_type: DBus::Type::STRING)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
with_inprogress_retry do
|
|
108
|
+
async_call("WriteValue(#{uuid})", timeout: gatt_remaining_timeout(deadline, effective_timeout, 'WriteValue')) do |queue, _request_id, cancelled|
|
|
109
|
+
char_iface.WriteValue(bytes, options) do |reply|
|
|
110
|
+
next if cancelled[0] # Discard late callback
|
|
111
|
+
if reply.is_a?(DBus::Error)
|
|
112
|
+
queue.push([reply, nil])
|
|
113
|
+
else
|
|
114
|
+
queue.push([nil, :ok])
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
true
|
|
121
|
+
rescue DBus::Error => e
|
|
122
|
+
translate_gatt_error(e, char_path, 'Write')
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Async start notifications (idempotent)
|
|
126
|
+
#
|
|
127
|
+
# @param char_path [String] D-Bus characteristic path
|
|
128
|
+
# @param timeout [Numeric] Total timeout in seconds for the entire operation (default: 5)
|
|
129
|
+
# @return [Boolean] true on success
|
|
130
|
+
# @raise [TimeoutError] if call times out
|
|
131
|
+
# @raise [NotifyNotSupported] if characteristic doesn't support notifications
|
|
132
|
+
# @raise [NotConnectedError] if device disconnected
|
|
133
|
+
# @raise [OperationInProgressError] if retries exhausted
|
|
134
|
+
def async_start_notify(char_path, timeout: DEFAULT_NOTIFY_TIMEOUT)
|
|
135
|
+
uuid = extract_uuid_from_path(char_path)
|
|
136
|
+
deadline = Time.now + timeout
|
|
137
|
+
|
|
138
|
+
proxy = async_introspect(char_path, timeout: gatt_remaining_timeout(deadline, timeout, 'StartNotify'))
|
|
139
|
+
char_iface = proxy[GATT_CHARACTERISTIC_INTERFACE]
|
|
140
|
+
|
|
141
|
+
# Check flags before calling (use async to avoid deadlock)
|
|
142
|
+
flags = async_get_property(char_path, GATT_CHARACTERISTIC_INTERFACE, 'Flags', timeout: gatt_remaining_timeout(deadline, timeout, 'StartNotify'))
|
|
143
|
+
unless flags.include?('notify') || flags.include?('indicate')
|
|
144
|
+
raise NotifyNotSupported.new(uuid, flags)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Idempotent: return early if already notifying (use async to avoid deadlock)
|
|
148
|
+
notifying = async_get_property(char_path, GATT_CHARACTERISTIC_INTERFACE, 'Notifying', timeout: gatt_remaining_timeout(deadline, timeout, 'StartNotify'))
|
|
149
|
+
return true if notifying
|
|
150
|
+
|
|
151
|
+
with_inprogress_retry do
|
|
152
|
+
async_call("StartNotify(#{uuid})", timeout: gatt_remaining_timeout(deadline, timeout, 'StartNotify')) do |queue, _request_id, cancelled|
|
|
153
|
+
char_iface.StartNotify do |reply|
|
|
154
|
+
next if cancelled[0] # Discard late callback
|
|
155
|
+
if reply.is_a?(DBus::Error)
|
|
156
|
+
queue.push([reply, nil])
|
|
157
|
+
else
|
|
158
|
+
queue.push([nil, :ok])
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
true
|
|
165
|
+
rescue DBus::Error => e
|
|
166
|
+
translate_gatt_error(e, char_path, 'StartNotify')
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Async stop notifications
|
|
170
|
+
#
|
|
171
|
+
# @param char_path [String] D-Bus characteristic path
|
|
172
|
+
# @param timeout [Numeric] Total timeout in seconds for the entire operation (default: 5)
|
|
173
|
+
# @return [Boolean] true on success
|
|
174
|
+
# @raise [TimeoutError] if call times out
|
|
175
|
+
# @raise [NotConnectedError] if device disconnected
|
|
176
|
+
def async_stop_notify(char_path, timeout: DEFAULT_NOTIFY_TIMEOUT)
|
|
177
|
+
uuid = extract_uuid_from_path(char_path)
|
|
178
|
+
deadline = Time.now + timeout
|
|
179
|
+
|
|
180
|
+
proxy = async_introspect(char_path, timeout: gatt_remaining_timeout(deadline, timeout, 'StopNotify'))
|
|
181
|
+
char_iface = proxy[GATT_CHARACTERISTIC_INTERFACE]
|
|
182
|
+
|
|
183
|
+
async_call("StopNotify(#{uuid})", timeout: gatt_remaining_timeout(deadline, timeout, 'StopNotify')) do |queue, _request_id, cancelled|
|
|
184
|
+
char_iface.StopNotify do |reply|
|
|
185
|
+
next if cancelled[0] # Discard late callback
|
|
186
|
+
if reply.is_a?(DBus::Error)
|
|
187
|
+
queue.push([reply, nil])
|
|
188
|
+
else
|
|
189
|
+
queue.push([nil, :ok])
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
true
|
|
195
|
+
rescue DBus::Error => e
|
|
196
|
+
translate_gatt_error(e, char_path, 'StopNotify')
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
# Calculate remaining timeout from deadline, raising TimeoutError if expired
|
|
202
|
+
# @param deadline [Time] Absolute deadline
|
|
203
|
+
# @param original_timeout [Numeric] Original timeout for error message
|
|
204
|
+
# @param operation [String] Operation name for error message
|
|
205
|
+
# @return [Numeric] Remaining seconds (minimum 0.1 to allow one more attempt)
|
|
206
|
+
# @raise [TimeoutError] if deadline has passed
|
|
207
|
+
def gatt_remaining_timeout(deadline, original_timeout, operation)
|
|
208
|
+
remaining = deadline - Time.now
|
|
209
|
+
raise TimeoutError.new(operation, original_timeout) if remaining <= 0
|
|
210
|
+
[remaining, 0.1].max
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Translate D-Bus errors to RBLE errors with UUID
|
|
214
|
+
# CONTEXT.md decision: error messages must include characteristic UUID
|
|
215
|
+
def translate_gatt_error(error, char_path, operation)
|
|
216
|
+
uuid = extract_uuid_from_path(char_path) || char_path
|
|
217
|
+
|
|
218
|
+
case error.name
|
|
219
|
+
when 'org.bluez.Error.NotSupported'
|
|
220
|
+
raise NotSupportedError, "#{operation} on #{uuid}"
|
|
221
|
+
when 'org.bluez.Error.NotPermitted'
|
|
222
|
+
raise NotPermittedError, "#{operation} on #{uuid}"
|
|
223
|
+
when 'org.bluez.Error.NotAuthorized'
|
|
224
|
+
raise NotAuthorizedError, "#{operation} on #{uuid}"
|
|
225
|
+
when 'org.bluez.Error.AuthenticationFailed'
|
|
226
|
+
raise AuthenticationError.new
|
|
227
|
+
when 'org.bluez.Error.NotConnected'
|
|
228
|
+
raise ConnectionLostError.new(nil, "#{operation} on #{uuid}")
|
|
229
|
+
when 'org.bluez.Error.InProgress'
|
|
230
|
+
raise OperationInProgressError.new("#{operation} on #{uuid}")
|
|
231
|
+
when 'org.bluez.Error.InvalidValueLength'
|
|
232
|
+
raise WriteError, "Invalid data length for #{uuid} (org.bluez.Error.InvalidValueLength)"
|
|
233
|
+
when 'org.bluez.Error.InvalidOffset'
|
|
234
|
+
raise ReadError, "Invalid offset for #{uuid} (org.bluez.Error.InvalidOffset)"
|
|
235
|
+
when 'org.bluez.Error.Failed'
|
|
236
|
+
raise GATTError, "#{operation} failed on #{uuid}: #{error.message} (org.bluez.Error.Failed)"
|
|
237
|
+
else
|
|
238
|
+
raise GATTError, "#{operation} failed on #{uuid}: #{error.message} (#{error.name})"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Extract UUID from characteristic path for error messages
|
|
243
|
+
# Path format: /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/serviceXXXX/charYYYY
|
|
244
|
+
def extract_uuid_from_path(char_path)
|
|
245
|
+
char_path.split('/').last
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dbus'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require_relative 'async_call'
|
|
6
|
+
|
|
7
|
+
module RBLE
|
|
8
|
+
module BlueZ
|
|
9
|
+
# Provides async D-Bus introspection with caching.
|
|
10
|
+
# Use this to introspect D-Bus objects without blocking the event loop.
|
|
11
|
+
#
|
|
12
|
+
# This module builds on AsyncCall to provide async introspection and
|
|
13
|
+
# GetManagedObjects calls. Results are cached for session lifetime.
|
|
14
|
+
#
|
|
15
|
+
# Including class must:
|
|
16
|
+
# - Include AsyncCall module (provides async_call method)
|
|
17
|
+
# - Provide a `service` method returning the D-Bus service
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# include AsyncCall
|
|
21
|
+
# include AsyncIntrospection
|
|
22
|
+
#
|
|
23
|
+
# proxy = async_introspect("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF")
|
|
24
|
+
# managed = async_get_managed_objects
|
|
25
|
+
#
|
|
26
|
+
module AsyncIntrospection
|
|
27
|
+
INTROSPECTABLE_INTERFACE = 'org.freedesktop.DBus.Introspectable'
|
|
28
|
+
OBJECT_MANAGER_INTERFACE = 'org.freedesktop.DBus.ObjectManager'
|
|
29
|
+
ROOT_PATH = '/'
|
|
30
|
+
|
|
31
|
+
# Asynchronously introspect a D-Bus object by path
|
|
32
|
+
#
|
|
33
|
+
# Returns cached ProxyObject if available, otherwise performs async
|
|
34
|
+
# introspection and caches the result.
|
|
35
|
+
#
|
|
36
|
+
# @param path [String] D-Bus object path to introspect
|
|
37
|
+
# @param timeout [Numeric] Timeout in seconds (default: 5)
|
|
38
|
+
# @return [DBus::ProxyObject] Introspected proxy object
|
|
39
|
+
# @raise [TimeoutError] if introspection times out
|
|
40
|
+
# @raise [DBus::Error] if D-Bus call fails
|
|
41
|
+
def async_introspect(path, timeout: 5)
|
|
42
|
+
@introspection_cache ||= {}
|
|
43
|
+
|
|
44
|
+
# Return cached result if present
|
|
45
|
+
return @introspection_cache[path] if @introspection_cache.key?(path)
|
|
46
|
+
|
|
47
|
+
# Perform async introspection
|
|
48
|
+
proxy = perform_async_introspect(path, timeout: timeout)
|
|
49
|
+
|
|
50
|
+
# Cache and return
|
|
51
|
+
@introspection_cache[path] = proxy
|
|
52
|
+
proxy
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Asynchronously get all managed objects from ObjectManager
|
|
56
|
+
#
|
|
57
|
+
# Creates the D-Bus message directly to avoid triggering synchronous
|
|
58
|
+
# introspection when accessing proxy interfaces.
|
|
59
|
+
#
|
|
60
|
+
# @param timeout [Numeric] Timeout in seconds (default: 10)
|
|
61
|
+
# @return [Hash] path => { interface_name => { property => value } }
|
|
62
|
+
# @raise [TimeoutError] if call times out
|
|
63
|
+
# @raise [DBus::Error] if D-Bus call fails
|
|
64
|
+
def async_get_managed_objects(timeout: 10)
|
|
65
|
+
result = async_call('GetManagedObjects', timeout: timeout) do |queue, _request_id, cancelled|
|
|
66
|
+
# Create message directly to avoid sync introspection
|
|
67
|
+
msg = DBus::Message.new(DBus::Message::METHOD_CALL)
|
|
68
|
+
msg.path = ROOT_PATH
|
|
69
|
+
msg.interface = OBJECT_MANAGER_INTERFACE
|
|
70
|
+
msg.destination = service.name
|
|
71
|
+
msg.member = 'GetManagedObjects'
|
|
72
|
+
msg.sender = service.connection.unique_name
|
|
73
|
+
|
|
74
|
+
service.connection.send_sync_or_async(msg) do |reply, *params|
|
|
75
|
+
next if cancelled[0] # Discard late callback
|
|
76
|
+
if reply.is_a?(DBus::Error)
|
|
77
|
+
queue.push([reply, nil])
|
|
78
|
+
else
|
|
79
|
+
# params contains the method return values (first is the Hash)
|
|
80
|
+
queue.push([nil, params.first])
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
result
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Clear cache and re-introspect a path
|
|
89
|
+
#
|
|
90
|
+
# Forces a fresh introspection even if the path is cached.
|
|
91
|
+
#
|
|
92
|
+
# @param path [String] D-Bus object path to refresh
|
|
93
|
+
# @param timeout [Numeric] Timeout in seconds (default: 5)
|
|
94
|
+
# @return [DBus::ProxyObject] Fresh introspected proxy object
|
|
95
|
+
# @raise [TimeoutError] if introspection times out
|
|
96
|
+
# @raise [DBus::Error] if D-Bus call fails
|
|
97
|
+
def refresh_introspection(path, timeout: 5)
|
|
98
|
+
@introspection_cache ||= {}
|
|
99
|
+
|
|
100
|
+
# Clear cache entry
|
|
101
|
+
@introspection_cache.delete(path)
|
|
102
|
+
|
|
103
|
+
# Perform fresh introspection (will be cached by async_introspect)
|
|
104
|
+
async_introspect(path, timeout: timeout)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Remove a path from the introspection cache
|
|
108
|
+
#
|
|
109
|
+
# Call this when a device is removed to clean up stale cache entries.
|
|
110
|
+
#
|
|
111
|
+
# @param path [String] D-Bus object path to remove from cache
|
|
112
|
+
# @return [void]
|
|
113
|
+
def clear_introspection(path)
|
|
114
|
+
@introspection_cache ||= {}
|
|
115
|
+
@introspection_cache.delete(path)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Perform the actual async introspection
|
|
121
|
+
#
|
|
122
|
+
# Uses connection.introspect_data with a block to avoid triggering
|
|
123
|
+
# synchronous introspection when accessing proxy interfaces.
|
|
124
|
+
#
|
|
125
|
+
# @param path [String] D-Bus object path
|
|
126
|
+
# @param timeout [Numeric] Timeout in seconds
|
|
127
|
+
# @return [DBus::ProxyObject] Introspected proxy object
|
|
128
|
+
def perform_async_introspect(path, timeout:)
|
|
129
|
+
# Create proxy without triggering auto-introspection
|
|
130
|
+
proxy = service.object(path)
|
|
131
|
+
|
|
132
|
+
# Use async_call with direct introspect_data call (supports async via block)
|
|
133
|
+
xml = async_call('Introspect', timeout: timeout) do |queue, _request_id, cancelled|
|
|
134
|
+
service.connection.introspect_data(service.name, path) do |xml_result|
|
|
135
|
+
next if cancelled[0] # Discard late callback
|
|
136
|
+
if xml_result.is_a?(DBus::Error)
|
|
137
|
+
queue.push([xml_result, nil])
|
|
138
|
+
else
|
|
139
|
+
queue.push([nil, xml_result])
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Parse introspection XML into proxy object
|
|
145
|
+
DBus::ProxyObjectFactory.introspect_into(proxy, xml)
|
|
146
|
+
|
|
147
|
+
proxy
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBLE
|
|
4
|
+
module BlueZ
|
|
5
|
+
# Manages D-Bus system bus connection to BlueZ
|
|
6
|
+
class DBusConnection
|
|
7
|
+
attr_reader :bus, :service, :root_object
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@bus = nil
|
|
11
|
+
@service = nil
|
|
12
|
+
@root_object = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Connect to D-Bus system bus
|
|
16
|
+
# Uses ASystemBus (non-singleton) to avoid state issues when switching between
|
|
17
|
+
# event loop processing (DBus::Main) and synchronous calls (send_sync)
|
|
18
|
+
# @raise [PermissionError] if permission denied
|
|
19
|
+
# @raise [Error] if BlueZ service not available
|
|
20
|
+
def connect
|
|
21
|
+
@bus = DBus::ASystemBus.new
|
|
22
|
+
@service = @bus.service(BLUEZ_SERVICE)
|
|
23
|
+
|
|
24
|
+
# Pre-introspect root for ObjectManager (REL-01: before async context)
|
|
25
|
+
# This ensures ObjectManager is available without async introspection
|
|
26
|
+
@root_object = @service.object('/')
|
|
27
|
+
@root_object.introspect
|
|
28
|
+
rescue DBus::Error => e
|
|
29
|
+
if e.message.include?('AccessDenied') || e.message.include?('Permission')
|
|
30
|
+
raise PermissionError.new('connect to D-Bus')
|
|
31
|
+
end
|
|
32
|
+
raise Error, "Failed to connect to BlueZ D-Bus service: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get object manager interface
|
|
36
|
+
# @return [DBus::ProxyObjectInterface]
|
|
37
|
+
def object_manager
|
|
38
|
+
root_object[OBJECT_MANAGER_INTERFACE]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Get a D-Bus object by path
|
|
42
|
+
# @param path [String] D-Bus object path
|
|
43
|
+
# @return [DBus::ProxyObject]
|
|
44
|
+
def object(path)
|
|
45
|
+
obj = @service.object(path)
|
|
46
|
+
obj.introspect
|
|
47
|
+
obj
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if connected
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def connected?
|
|
53
|
+
!@bus.nil? && !@service.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Disconnect (cleanup)
|
|
57
|
+
def disconnect
|
|
58
|
+
@root_object = nil
|
|
59
|
+
@service = nil
|
|
60
|
+
@bus = nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|