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,539 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBLE
|
|
4
|
+
# Represents a connection to a BLE device
|
|
5
|
+
#
|
|
6
|
+
# Provides access to GATT services and characteristics for reading,
|
|
7
|
+
# writing, and subscribing to values. Includes a state machine for
|
|
8
|
+
# tracking connection lifecycle and callback support for disconnect
|
|
9
|
+
# notifications.
|
|
10
|
+
#
|
|
11
|
+
# @example Connect and read a characteristic
|
|
12
|
+
# connection = RBLE.connect(device.address)
|
|
13
|
+
# connection.discover_services
|
|
14
|
+
# hr_service = connection.service('180d')
|
|
15
|
+
# measurement = hr_service.characteristic('2a37')
|
|
16
|
+
# # Future: value = measurement.read (Plan 04)
|
|
17
|
+
# connection.disconnect
|
|
18
|
+
#
|
|
19
|
+
# @example Register disconnect callback
|
|
20
|
+
# connection = RBLE.connect(device.address)
|
|
21
|
+
# connection.on_disconnect { |reason| puts "Disconnected: #{reason}" }
|
|
22
|
+
# connection.on_state_change { |old_state, new_state| puts "#{old_state} -> #{new_state}" }
|
|
23
|
+
#
|
|
24
|
+
class Connection
|
|
25
|
+
# Valid state transitions for the connection lifecycle
|
|
26
|
+
# @return [Hash{Symbol => Array<Symbol>}]
|
|
27
|
+
VALID_TRANSITIONS = {
|
|
28
|
+
disconnected: [:connecting],
|
|
29
|
+
connecting: [:connected, :disconnected],
|
|
30
|
+
connected: [:disconnecting, :discovering_services, :disconnected],
|
|
31
|
+
discovering_services: [:connected, :disconnected],
|
|
32
|
+
disconnecting: [:disconnected]
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
attr_reader :address, :device_path, :dbus_session
|
|
36
|
+
|
|
37
|
+
# Create a connection (internal - use RBLE.connect)
|
|
38
|
+
# @param address [String] Device MAC address
|
|
39
|
+
# @param device_path [String] D-Bus device path
|
|
40
|
+
# @param backend [Backend::Base] Platform backend instance
|
|
41
|
+
def initialize(address:, device_path:, backend:)
|
|
42
|
+
@address = address
|
|
43
|
+
@device_path = device_path
|
|
44
|
+
@backend = backend
|
|
45
|
+
@services = nil
|
|
46
|
+
@dbus_session = nil
|
|
47
|
+
|
|
48
|
+
# State machine infrastructure
|
|
49
|
+
@state = :connecting
|
|
50
|
+
@state_mutex = Mutex.new
|
|
51
|
+
@state_callbacks = []
|
|
52
|
+
@disconnect_callbacks = []
|
|
53
|
+
|
|
54
|
+
# Notification thread infrastructure
|
|
55
|
+
@notification_thread = nil
|
|
56
|
+
@notification_mutex = Mutex.new
|
|
57
|
+
|
|
58
|
+
# GATT operation queue for serializing read/write/subscribe operations
|
|
59
|
+
@gatt_queue = nil
|
|
60
|
+
|
|
61
|
+
# Create owned D-Bus session for BlueZ backend
|
|
62
|
+
# Each Connection gets its own D-Bus connection + event loop to avoid
|
|
63
|
+
# state corruption issues when shared connections are used
|
|
64
|
+
setup_dbus_session if bluez_backend?
|
|
65
|
+
|
|
66
|
+
# Transition to connected (connection already established by RBLE.connect)
|
|
67
|
+
warn " [RBLE] Connection.new: transitioning to connected..." if RBLE.trace
|
|
68
|
+
transition_to(:connected)
|
|
69
|
+
warn " [RBLE] Connection.new: done" if RBLE.trace
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Get the current connection state
|
|
73
|
+
# @return [Symbol] One of :disconnected, :connecting, :connected, :disconnecting, :discovering_services
|
|
74
|
+
def state
|
|
75
|
+
@state_mutex.synchronize { @state }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check if still connected
|
|
79
|
+
# @return [Boolean]
|
|
80
|
+
def connected?
|
|
81
|
+
state == :connected
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Register a callback for disconnect events
|
|
85
|
+
# @yield [Symbol] Called with disconnect reason when disconnection occurs
|
|
86
|
+
# @yieldparam reason [Symbol] The disconnect reason (:user_requested, :link_loss, :timeout, etc.)
|
|
87
|
+
# @raise [ArgumentError] if no block given
|
|
88
|
+
# @return [void]
|
|
89
|
+
def on_disconnect(&block)
|
|
90
|
+
raise ArgumentError, 'Block required for on_disconnect' unless block_given?
|
|
91
|
+
|
|
92
|
+
@state_mutex.synchronize { @disconnect_callbacks << block }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Register a callback for state change events
|
|
96
|
+
# @yield [Symbol, Symbol] Called with (old_state, new_state) on every state transition
|
|
97
|
+
# @yieldparam old_state [Symbol] The previous state
|
|
98
|
+
# @yieldparam new_state [Symbol] The new state
|
|
99
|
+
# @raise [ArgumentError] if no block given
|
|
100
|
+
# @return [void]
|
|
101
|
+
def on_state_change(&block)
|
|
102
|
+
raise ArgumentError, 'Block required for on_state_change' unless block_given?
|
|
103
|
+
|
|
104
|
+
@state_mutex.synchronize { @state_callbacks << block }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Discover GATT services on the connected device
|
|
108
|
+
# Must be called before accessing services
|
|
109
|
+
#
|
|
110
|
+
# @param timeout [Numeric] Discovery timeout in seconds (default: 30)
|
|
111
|
+
# @return [Array<Service>] Discovered services with ActiveCharacteristic instances
|
|
112
|
+
# @raise [NotConnectedError] if not connected
|
|
113
|
+
# @raise [ServiceDiscoveryError] if discovery fails
|
|
114
|
+
def discover_services(timeout: 30)
|
|
115
|
+
raise NotConnectedError unless connected?
|
|
116
|
+
|
|
117
|
+
transition_to(:discovering_services)
|
|
118
|
+
begin
|
|
119
|
+
# Pass self so backend can use our D-Bus session
|
|
120
|
+
raw_services = @backend.discover_services(@device_path, connection: self, timeout: timeout)
|
|
121
|
+
services = build_services_with_active_characteristics(raw_services)
|
|
122
|
+
@state_mutex.synchronize { @services = services }
|
|
123
|
+
transition_to(:connected)
|
|
124
|
+
services
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
# On failure, return to connected state (if not already disconnected)
|
|
127
|
+
RBLE.logger&.debug("[RBLE] Service discovery failed: #{e.class}: #{e.message}")
|
|
128
|
+
transition_to(:connected) if state == :discovering_services
|
|
129
|
+
raise
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Get all discovered services
|
|
134
|
+
# @return [Array<Service>]
|
|
135
|
+
# @raise [ServiceDiscoveryError] if discover_services not called
|
|
136
|
+
def services
|
|
137
|
+
svcs = @state_mutex.synchronize { @services }
|
|
138
|
+
raise ServiceDiscoveryError, 'Call discover_services first' if svcs.nil?
|
|
139
|
+
|
|
140
|
+
svcs
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Get list of currently subscribed characteristic UUIDs
|
|
144
|
+
# Useful for debugging and status checking
|
|
145
|
+
# @return [Array<String>] UUIDs of subscribed characteristics (empty if none)
|
|
146
|
+
def active_subscriptions
|
|
147
|
+
return [] unless @backend.respond_to?(:subscriptions_for_connection)
|
|
148
|
+
|
|
149
|
+
@backend.subscriptions_for_connection(self).filter_map { |sub| sub[:uuid] }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Find a service by UUID
|
|
153
|
+
# Supports both full UUID and short UUID (e.g., "180d")
|
|
154
|
+
#
|
|
155
|
+
# @param uuid [String] Service UUID to find
|
|
156
|
+
# @return [Service]
|
|
157
|
+
# @raise [ServiceNotFoundError] if service not found
|
|
158
|
+
# @raise [ServiceDiscoveryError] if discover_services not called
|
|
159
|
+
def service(uuid)
|
|
160
|
+
normalized = normalize_uuid(uuid)
|
|
161
|
+
found = services.find { |s| s.uuid.downcase == normalized || s.short_uuid == uuid.downcase }
|
|
162
|
+
raise ServiceNotFoundError, uuid unless found
|
|
163
|
+
|
|
164
|
+
found
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Disconnect from the device
|
|
168
|
+
# @return [void]
|
|
169
|
+
def disconnect
|
|
170
|
+
current = state
|
|
171
|
+
return if current == :disconnected || current == :disconnecting
|
|
172
|
+
|
|
173
|
+
transition_to(:disconnecting)
|
|
174
|
+
begin
|
|
175
|
+
# Unregister from backend before disconnect to avoid receiving our own disconnect event
|
|
176
|
+
@backend.unregister_connection(@device_path) if @backend.respond_to?(:unregister_connection)
|
|
177
|
+
@backend.disconnect_device(@device_path)
|
|
178
|
+
ensure
|
|
179
|
+
# Destroy D-Bus session (stops event loop, closes connection)
|
|
180
|
+
destroy_dbus_session
|
|
181
|
+
transition_to(:disconnected, reason: :user_requested)
|
|
182
|
+
@state_mutex.synchronize { @services = nil }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Handle disconnection detected by backend
|
|
187
|
+
# @param reason [Symbol] Disconnect reason (:link_loss, :timeout, :remote_disconnect, etc.)
|
|
188
|
+
# @return [Boolean] true if transition happened, false if already disconnected
|
|
189
|
+
def handle_disconnect(reason)
|
|
190
|
+
return false if state == :disconnected
|
|
191
|
+
|
|
192
|
+
# Destroy D-Bus session on unexpected disconnect
|
|
193
|
+
destroy_dbus_session
|
|
194
|
+
transition_to(:disconnected, reason: reason)
|
|
195
|
+
@state_mutex.synchronize { @services = nil }
|
|
196
|
+
true
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Process events from the Connection's event loop
|
|
200
|
+
# Call this to receive notifications from subscribed characteristics
|
|
201
|
+
# @param timeout [Numeric, nil] Timeout in seconds (nil = block forever)
|
|
202
|
+
# @yield [Event] Called for each event (optional - handles notifications automatically)
|
|
203
|
+
# @return [Boolean] true if shutdown received, false if timeout
|
|
204
|
+
def process_events(timeout: nil, &block)
|
|
205
|
+
return false unless @dbus_session
|
|
206
|
+
|
|
207
|
+
@dbus_session.process_events(timeout: timeout) do |event|
|
|
208
|
+
handle_connection_event(event)
|
|
209
|
+
block&.call(event)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Drain all pending events without blocking
|
|
214
|
+
# @yield [Event] Called for each event (optional)
|
|
215
|
+
# @return [Integer] Number of events processed
|
|
216
|
+
def drain_events(&block)
|
|
217
|
+
return 0 unless @dbus_session
|
|
218
|
+
|
|
219
|
+
@dbus_session.drain_events do |event|
|
|
220
|
+
handle_connection_event(event)
|
|
221
|
+
block&.call(event)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Ensure notification processing is running
|
|
226
|
+
# Called by backend when first subscription is registered
|
|
227
|
+
# Starts background thread for BlueZ backend
|
|
228
|
+
# @return [void]
|
|
229
|
+
def ensure_notification_processing
|
|
230
|
+
return unless bluez_backend?
|
|
231
|
+
|
|
232
|
+
start_notification_thread
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Get or create GATT operation queue for this connection
|
|
236
|
+
# Queue is started on first access and stopped on disconnect
|
|
237
|
+
# @return [RBLE::BlueZ::GattOperationQueue]
|
|
238
|
+
def gatt_queue
|
|
239
|
+
@notification_mutex.synchronize do
|
|
240
|
+
return @gatt_queue if @gatt_queue&.running?
|
|
241
|
+
|
|
242
|
+
@gatt_queue = RBLE::BlueZ::GattOperationQueue.new
|
|
243
|
+
@gatt_queue.start
|
|
244
|
+
@gatt_queue
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
# Handle events from the Connection's event loop
|
|
251
|
+
# Notifications are dispatched by background thread when running
|
|
252
|
+
# This method is used for manual process_events calls
|
|
253
|
+
# @param event [Event] Event to handle
|
|
254
|
+
def handle_connection_event(event)
|
|
255
|
+
case event.type
|
|
256
|
+
when :notification
|
|
257
|
+
# Background thread dispatches notifications automatically
|
|
258
|
+
# Only dispatch here if no background thread (manual process_events)
|
|
259
|
+
unless @notification_thread&.alive?
|
|
260
|
+
data = event.data
|
|
261
|
+
data[:callback]&.call(data[:value]) if data.is_a?(Hash)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Start background thread for notification processing
|
|
267
|
+
# Thread starts on first subscribe and runs until disconnect
|
|
268
|
+
# @return [void]
|
|
269
|
+
def start_notification_thread
|
|
270
|
+
@notification_mutex.synchronize do
|
|
271
|
+
return if @notification_thread&.alive?
|
|
272
|
+
return unless @dbus_session
|
|
273
|
+
|
|
274
|
+
@notification_thread = Thread.new do
|
|
275
|
+
Thread.current.name = 'rble-notify'
|
|
276
|
+
notification_loop
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Main notification processing loop
|
|
282
|
+
# Runs until disconnect, dispatching callbacks with exception handling
|
|
283
|
+
# @return [void]
|
|
284
|
+
def notification_loop
|
|
285
|
+
RBLE.logger&.debug('[RBLE] Notification thread started')
|
|
286
|
+
while connected? && @dbus_session
|
|
287
|
+
begin
|
|
288
|
+
@dbus_session.process_events(timeout: 0.1) do |event|
|
|
289
|
+
dispatch_notification_safely(event) if event.type == :notification
|
|
290
|
+
end
|
|
291
|
+
rescue StandardError => e
|
|
292
|
+
# Log at debug level and continue - don't let errors kill the thread
|
|
293
|
+
RBLE.logger&.debug("[RBLE] Notification thread error: #{e.class}: #{e.message}")
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
RBLE.logger&.debug('[RBLE] Notification thread stopped')
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Dispatch notification callback with exception protection
|
|
300
|
+
# User callback exceptions are logged but don't kill the thread
|
|
301
|
+
# @param event [Event] Notification event to dispatch
|
|
302
|
+
# @return [void]
|
|
303
|
+
def dispatch_notification_safely(event)
|
|
304
|
+
return unless connected?
|
|
305
|
+
|
|
306
|
+
data = event.data
|
|
307
|
+
return unless data.is_a?(Hash) && data[:callback]
|
|
308
|
+
|
|
309
|
+
RBLE.logger&.debug('[RBLE] Dispatching notification callback')
|
|
310
|
+
begin
|
|
311
|
+
data[:callback].call(data[:value])
|
|
312
|
+
rescue StandardError => e
|
|
313
|
+
RBLE.logger&.debug("[RBLE] Callback raised: #{e.class}: #{e.message}")
|
|
314
|
+
# Continue processing - don't let buggy callbacks break notification delivery
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Build Service objects with ActiveCharacteristic instances
|
|
319
|
+
# @param raw_services [Array<Hash>] Service data from backend
|
|
320
|
+
# @return [Array<Service>] Services with active characteristics
|
|
321
|
+
def build_services_with_active_characteristics(raw_services)
|
|
322
|
+
raw_services.map do |service_data|
|
|
323
|
+
characteristics = service_data[:characteristics].map do |char_info|
|
|
324
|
+
ActiveCharacteristic.new(
|
|
325
|
+
characteristic: char_info[:data],
|
|
326
|
+
path: char_info[:path],
|
|
327
|
+
connection: self,
|
|
328
|
+
backend: @backend,
|
|
329
|
+
descriptors: char_info[:descriptors] || []
|
|
330
|
+
)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
Service.new(
|
|
334
|
+
uuid: service_data[:uuid],
|
|
335
|
+
primary: service_data[:primary],
|
|
336
|
+
characteristics: characteristics
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Normalize a short UUID to full 128-bit format
|
|
342
|
+
# @param short_uuid [String] Short or full UUID
|
|
343
|
+
# @return [String] Full 128-bit UUID in lowercase
|
|
344
|
+
def normalize_uuid(short_uuid)
|
|
345
|
+
if short_uuid.length == 4
|
|
346
|
+
"0000#{short_uuid.downcase}-0000-1000-8000-00805f9b34fb"
|
|
347
|
+
else
|
|
348
|
+
short_uuid.downcase
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Check if using BlueZ backend
|
|
353
|
+
# @return [Boolean]
|
|
354
|
+
def bluez_backend?
|
|
355
|
+
# Use class name check to avoid requiring BlueZ backend when not needed
|
|
356
|
+
@backend.class.name == 'RBLE::Backend::BlueZ'
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Setup D-Bus session for BlueZ backend
|
|
360
|
+
# Creates a new DBusSession, connects, sets up disconnect monitoring, and starts event loop
|
|
361
|
+
# Signal handlers MUST be registered BEFORE starting the event loop to avoid
|
|
362
|
+
# blocking on synchronous AddMatch calls while the loop is running.
|
|
363
|
+
# @return [void]
|
|
364
|
+
# @raise [Error] if session creation fails
|
|
365
|
+
def setup_dbus_session
|
|
366
|
+
warn " [RBLE] Connection: creating DBusSession..." if RBLE.trace
|
|
367
|
+
@dbus_session = RBLE::BlueZ::DBusSession.new
|
|
368
|
+
@dbus_session.connect
|
|
369
|
+
|
|
370
|
+
# Register disconnect signal handler BEFORE starting event loop
|
|
371
|
+
# This avoids the 15-20s blocking that occurs when on_signal is called
|
|
372
|
+
# while the event loop is already running
|
|
373
|
+
warn " [RBLE] Connection: setting up disconnect handler..." if RBLE.trace
|
|
374
|
+
setup_disconnect_signal_handler
|
|
375
|
+
|
|
376
|
+
warn " [RBLE] Connection: starting event loop..." if RBLE.trace
|
|
377
|
+
@dbus_session.start_event_loop
|
|
378
|
+
warn " [RBLE] Connection: setup complete" if RBLE.trace
|
|
379
|
+
rescue StandardError => e
|
|
380
|
+
# Clean up partial session on failure
|
|
381
|
+
@dbus_session&.disconnect
|
|
382
|
+
@dbus_session = nil
|
|
383
|
+
raise Error, "Failed to create D-Bus session: #{e.message}"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Setup PropertiesChanged signal handler for disconnect detection
|
|
387
|
+
# Must be called BEFORE starting the event loop
|
|
388
|
+
# @return [void]
|
|
389
|
+
def setup_disconnect_signal_handler
|
|
390
|
+
return unless @dbus_session
|
|
391
|
+
|
|
392
|
+
# Get device object - use synchronous introspect since event loop not running yet
|
|
393
|
+
warn " [RBLE] Connection: introspecting device..." if RBLE.trace
|
|
394
|
+
start = Time.now
|
|
395
|
+
device_obj = @dbus_session.object(@device_path)
|
|
396
|
+
device_obj.introspect
|
|
397
|
+
warn " [RBLE] Connection: introspect done (#{(Time.now - start).round(2)}s)" if RBLE.trace
|
|
398
|
+
props_iface = device_obj[RBLE::BlueZ::PROPERTIES_INTERFACE]
|
|
399
|
+
|
|
400
|
+
warn " [RBLE] Connection: registering on_signal..." if RBLE.trace
|
|
401
|
+
start = Time.now
|
|
402
|
+
@dbus_session.register_signal_handler(props_iface, 'PropertiesChanged') do |interface, changed, _invalidated|
|
|
403
|
+
next unless interface == RBLE::BlueZ::DEVICE_INTERFACE
|
|
404
|
+
next unless changed.key?('Connected')
|
|
405
|
+
|
|
406
|
+
if changed['Connected'] == false
|
|
407
|
+
# Device disconnected - notify via backend
|
|
408
|
+
@backend.send(:handle_unexpected_disconnect, @device_path) if @backend.respond_to?(:handle_unexpected_disconnect, true)
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
warn " [RBLE] Connection: on_signal done (#{(Time.now - start).round(2)}s)" if RBLE.trace
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Destroy D-Bus session
|
|
415
|
+
# Stops GATT queue, notification thread, event loop and closes connection
|
|
416
|
+
# @return [void]
|
|
417
|
+
def destroy_dbus_session
|
|
418
|
+
return unless @dbus_session
|
|
419
|
+
|
|
420
|
+
# Stop GATT operation queue first
|
|
421
|
+
@gatt_queue&.stop
|
|
422
|
+
@gatt_queue = nil
|
|
423
|
+
|
|
424
|
+
# Stop notification thread
|
|
425
|
+
@notification_mutex.synchronize do
|
|
426
|
+
if @notification_thread&.alive?
|
|
427
|
+
# Thread will exit naturally when it sees we're disconnected
|
|
428
|
+
# Give it a short timeout to exit gracefully
|
|
429
|
+
@notification_thread.join(1)
|
|
430
|
+
@notification_thread.kill if @notification_thread.alive?
|
|
431
|
+
end
|
|
432
|
+
@notification_thread = nil
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
begin
|
|
436
|
+
@dbus_session.disconnect
|
|
437
|
+
rescue StandardError => e
|
|
438
|
+
# Log warning but continue - fire and forget
|
|
439
|
+
warn "[RBLE] Failed to disconnect D-Bus session: #{e.message}"
|
|
440
|
+
ensure
|
|
441
|
+
@dbus_session = nil
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Transition to a new state, calling callbacks outside the mutex
|
|
446
|
+
# @param new_state [Symbol] The state to transition to
|
|
447
|
+
# @param reason [Symbol, nil] Disconnect reason (only for :disconnected state)
|
|
448
|
+
# @return [Boolean] true if transition succeeded, false if invalid
|
|
449
|
+
def transition_to(new_state, reason: nil)
|
|
450
|
+
old_state = nil
|
|
451
|
+
state_callbacks_copy = []
|
|
452
|
+
disconnect_callbacks_copy = []
|
|
453
|
+
|
|
454
|
+
@state_mutex.synchronize do
|
|
455
|
+
return false unless VALID_TRANSITIONS[@state]&.include?(new_state)
|
|
456
|
+
|
|
457
|
+
old_state = @state
|
|
458
|
+
@state = new_state
|
|
459
|
+
|
|
460
|
+
# Copy callbacks while holding lock
|
|
461
|
+
state_callbacks_copy = @state_callbacks.dup
|
|
462
|
+
disconnect_callbacks_copy = @disconnect_callbacks.dup if new_state == :disconnected
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Call callbacks outside mutex to prevent deadlocks
|
|
466
|
+
state_callbacks_copy.each do |callback|
|
|
467
|
+
begin
|
|
468
|
+
callback.call(old_state, new_state)
|
|
469
|
+
rescue StandardError => e
|
|
470
|
+
warn "[RBLE] State change callback error: #{e.message}"
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Call disconnect callbacks if transitioning to disconnected
|
|
475
|
+
if new_state == :disconnected
|
|
476
|
+
disconnect_callbacks_copy.each do |callback|
|
|
477
|
+
begin
|
|
478
|
+
callback.call(reason)
|
|
479
|
+
rescue StandardError => e
|
|
480
|
+
warn "[RBLE] Disconnect callback error: #{e.message}"
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
true
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
class << self
|
|
490
|
+
# Connect to a BLE device by address
|
|
491
|
+
#
|
|
492
|
+
# @param address [String] Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
|
|
493
|
+
# @param timeout [Numeric] Connection timeout in seconds (default: 30)
|
|
494
|
+
# @param adapter [String, nil] Bluetooth adapter name (e.g., "hci0")
|
|
495
|
+
# @param device_name [String, nil] Device name for error messages (from scan)
|
|
496
|
+
# @return [Connection] Connected device handle
|
|
497
|
+
# @raise [ConnectionTimeoutError] if connection times out
|
|
498
|
+
# @raise [DeviceNotFoundError] if device not found or disappeared
|
|
499
|
+
# @raise [ConnectionError] if connection fails
|
|
500
|
+
#
|
|
501
|
+
# @example
|
|
502
|
+
# conn = RBLE.connect("AA:BB:CC:DD:EE:FF", timeout: 10)
|
|
503
|
+
# conn.discover_services
|
|
504
|
+
# # ... use services ...
|
|
505
|
+
# conn.disconnect
|
|
506
|
+
#
|
|
507
|
+
def connect(address, timeout: 30, adapter: nil, device_name: nil)
|
|
508
|
+
backend = Backend.for_platform
|
|
509
|
+
|
|
510
|
+
# Convert address to D-Bus path
|
|
511
|
+
device_path = backend.device_path_for_address(address, adapter: adapter)
|
|
512
|
+
raise DeviceNotFoundError.new(address, device_name: device_name) unless device_path
|
|
513
|
+
|
|
514
|
+
# Connect via backend
|
|
515
|
+
begin
|
|
516
|
+
backend.connect_device(device_path, timeout: timeout)
|
|
517
|
+
rescue DeviceNotFoundError => e
|
|
518
|
+
# Re-raise with device name if we have it and error doesn't already include it
|
|
519
|
+
raise DeviceNotFoundError.new(e.address, device_name: device_name) if device_name && !e.device_name
|
|
520
|
+
raise
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
warn " [RBLE] RBLE.connect: creating Connection..." if RBLE.trace
|
|
524
|
+
connection = Connection.new(
|
|
525
|
+
address: address,
|
|
526
|
+
device_path: device_path,
|
|
527
|
+
backend: backend
|
|
528
|
+
)
|
|
529
|
+
warn " [RBLE] RBLE.connect: Connection created" if RBLE.trace
|
|
530
|
+
|
|
531
|
+
# Register connection for disconnect monitoring (BlueZ backend)
|
|
532
|
+
warn " [RBLE] RBLE.connect: registering connection..." if RBLE.trace
|
|
533
|
+
backend.register_connection(device_path, connection) if backend.respond_to?(:register_connection)
|
|
534
|
+
warn " [RBLE] RBLE.connect: done" if RBLE.trace
|
|
535
|
+
|
|
536
|
+
connection
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
end
|
data/lib/rble/device.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBLE
|
|
4
|
+
# Immutable snapshot of a discovered BLE device
|
|
5
|
+
#
|
|
6
|
+
# @!attribute address [String] MAC address (uppercase, colon-separated: "12:34:56:78:9A:BC")
|
|
7
|
+
# @!attribute name [String, nil] Device local name from advertisement
|
|
8
|
+
# @!attribute rssi [Integer, nil] Signal strength in dBm
|
|
9
|
+
# @!attribute manufacturer_data [Hash{Integer => Array<Integer>}] Company ID => byte array
|
|
10
|
+
# @!attribute manufacturer_data_raw [Hash{Integer => String}] Company ID => binary string
|
|
11
|
+
# @!attribute service_data [Hash{String => Array<Integer>}] UUID => byte array
|
|
12
|
+
# @!attribute service_uuids [Array<String>] Advertised service UUIDs
|
|
13
|
+
# @!attribute tx_power [Integer, nil] Transmit power level in dBm
|
|
14
|
+
# @!attribute address_type [String] "public" or "random"
|
|
15
|
+
Device = Data.define(
|
|
16
|
+
:address,
|
|
17
|
+
:name,
|
|
18
|
+
:rssi,
|
|
19
|
+
:manufacturer_data,
|
|
20
|
+
:manufacturer_data_raw,
|
|
21
|
+
:service_data,
|
|
22
|
+
:service_uuids,
|
|
23
|
+
:tx_power,
|
|
24
|
+
:address_type
|
|
25
|
+
) do
|
|
26
|
+
def initialize(
|
|
27
|
+
address:,
|
|
28
|
+
name: nil,
|
|
29
|
+
rssi: nil,
|
|
30
|
+
manufacturer_data: {},
|
|
31
|
+
manufacturer_data_raw: {},
|
|
32
|
+
service_data: {},
|
|
33
|
+
service_uuids: [],
|
|
34
|
+
tx_power: nil,
|
|
35
|
+
address_type: 'public'
|
|
36
|
+
)
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Create a new Device with updated attributes
|
|
41
|
+
# @param attrs [Hash] attributes to update
|
|
42
|
+
# @return [Device] new Device instance with updates
|
|
43
|
+
def update(**attrs)
|
|
44
|
+
with(**attrs)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get manufacturer data as a binary string for a given company ID
|
|
48
|
+
# @param company_id [Integer] BLE company identifier (e.g., 0x0499 for Ruuvi)
|
|
49
|
+
# @return [String, nil] Binary string (ASCII-8BIT) or nil if not present
|
|
50
|
+
def manufacturer_data_bytes(company_id)
|
|
51
|
+
manufacturer_data_raw[company_id]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|