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,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBLE
|
|
4
|
+
module BlueZ
|
|
5
|
+
# Per-connection queue for serializing GATT operations.
|
|
6
|
+
#
|
|
7
|
+
# BlueZ can behave unpredictably when multiple GATT operations (read, write,
|
|
8
|
+
# start_notify) are executed concurrently on the same connection. This queue
|
|
9
|
+
# ensures operations are executed sequentially with a small delay between them.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# queue = GattOperationQueue.new
|
|
13
|
+
# queue.enqueue { session.async_read_value(char_path) }
|
|
14
|
+
# queue.enqueue { session.async_write_value(char_path, [0x01]) }
|
|
15
|
+
# queue.stop # Call on disconnect
|
|
16
|
+
#
|
|
17
|
+
class GattOperationQueue
|
|
18
|
+
# Delay between GATT operations to prevent BlueZ contention
|
|
19
|
+
INTER_OPERATION_DELAY = 0.05
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@queue = Thread::Queue.new
|
|
23
|
+
@worker_thread = nil
|
|
24
|
+
@running = false
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@active_result_queue = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Enqueue a GATT operation for sequential execution
|
|
30
|
+
#
|
|
31
|
+
# @yield Block containing the GATT operation to execute
|
|
32
|
+
# @return [Object] Result from the block execution
|
|
33
|
+
# @raise [RuntimeError] if queue is stopped
|
|
34
|
+
# @raise Re-raises any exception from the block
|
|
35
|
+
def enqueue(&block)
|
|
36
|
+
raise 'GattOperationQueue is stopped' unless running?
|
|
37
|
+
|
|
38
|
+
result_queue = Thread::Queue.new
|
|
39
|
+
@queue.push([block, result_queue])
|
|
40
|
+
|
|
41
|
+
# Wait for result (blocking)
|
|
42
|
+
result, error = result_queue.pop
|
|
43
|
+
raise error if error
|
|
44
|
+
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Start the worker thread if not already running
|
|
49
|
+
# @return [void]
|
|
50
|
+
def start
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
return if @running
|
|
53
|
+
|
|
54
|
+
@running = true
|
|
55
|
+
@worker_thread = Thread.new { worker_loop }
|
|
56
|
+
@worker_thread.name = 'rble-gatt-queue'
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Stop the worker thread and clear pending operations
|
|
61
|
+
# @return [void]
|
|
62
|
+
def stop
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
return unless @running
|
|
65
|
+
|
|
66
|
+
@running = false
|
|
67
|
+
@queue.push(:shutdown)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Wait for worker thread to finish
|
|
71
|
+
@worker_thread&.join(2)
|
|
72
|
+
@worker_thread&.kill if @worker_thread&.alive?
|
|
73
|
+
@worker_thread = nil
|
|
74
|
+
|
|
75
|
+
# Unblock caller of the currently-executing operation (if any)
|
|
76
|
+
active_rq = @active_result_queue
|
|
77
|
+
@active_result_queue = nil
|
|
78
|
+
active_rq&.push([nil, RuntimeError.new('GATT queue stopped')])
|
|
79
|
+
|
|
80
|
+
# Drain remaining operations and unblock waiting callers
|
|
81
|
+
drain_pending_operations
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if queue is running
|
|
85
|
+
# @return [Boolean]
|
|
86
|
+
def running?
|
|
87
|
+
@mutex.synchronize { @running }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Drain pending operations, notifying callers with an error
|
|
93
|
+
# @return [void]
|
|
94
|
+
def drain_pending_operations
|
|
95
|
+
while (item = @queue.pop(true) rescue nil)
|
|
96
|
+
next if item == :shutdown
|
|
97
|
+
|
|
98
|
+
_block, result_queue = item
|
|
99
|
+
result_queue.push([nil, RuntimeError.new('GATT queue stopped')])
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def worker_loop
|
|
104
|
+
RBLE.logger&.debug('[RBLE] GATT operation queue started')
|
|
105
|
+
|
|
106
|
+
while @running
|
|
107
|
+
item = @queue.pop
|
|
108
|
+
break if item == :shutdown
|
|
109
|
+
|
|
110
|
+
block, result_queue = item
|
|
111
|
+
@active_result_queue = result_queue
|
|
112
|
+
begin
|
|
113
|
+
result = block.call
|
|
114
|
+
result_queue.push([result, nil])
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
result_queue.push([nil, e])
|
|
117
|
+
ensure
|
|
118
|
+
@active_result_queue = nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Inter-operation delay to prevent BlueZ contention
|
|
122
|
+
sleep(INTER_OPERATION_DELAY) if @running
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
RBLE.logger&.debug('[RBLE] GATT operation queue stopped')
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dbus'
|
|
4
|
+
|
|
5
|
+
module RBLE
|
|
6
|
+
module BlueZ
|
|
7
|
+
# D-Bus object implementing the org.bluez.Agent1 interface for BLE pairing.
|
|
8
|
+
#
|
|
9
|
+
# BlueZ invokes Agent1 methods during pairing to handle security prompts
|
|
10
|
+
# (PIN entry, passkey confirmation, authorization). This class exports those
|
|
11
|
+
# methods on the system bus so BlueZ can call back into the Ruby process.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# agent = PairingAgent.new(io_handler: handler, force: false)
|
|
15
|
+
# bus.object_server.export(agent)
|
|
16
|
+
#
|
|
17
|
+
class PairingAgent < DBus::Object
|
|
18
|
+
AGENT_INTERFACE = 'org.bluez.Agent1'
|
|
19
|
+
AGENT_PATH = '/org/rble/agent'
|
|
20
|
+
|
|
21
|
+
# @param io_handler [#display, #prompt, #confirm] IO handler for user interaction
|
|
22
|
+
# @param force [Boolean] Skip interactive prompts (auto-accept)
|
|
23
|
+
def initialize(io_handler:, force: false)
|
|
24
|
+
super(AGENT_PATH)
|
|
25
|
+
@io_handler = io_handler
|
|
26
|
+
@force = force
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
dbus_interface AGENT_INTERFACE do
|
|
30
|
+
# Called when the agent is unregistered
|
|
31
|
+
dbus_method :Release do
|
|
32
|
+
# no-op
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Request a PIN code from the user
|
|
36
|
+
# @param device [String] D-Bus object path of the device
|
|
37
|
+
# @return [String] PIN code
|
|
38
|
+
dbus_method :RequestPinCode, 'in device:o, out pincode:s' do |device|
|
|
39
|
+
if @force
|
|
40
|
+
@io_handler.display("Auto-accepting PIN for #{device_label(device)}")
|
|
41
|
+
['0000']
|
|
42
|
+
else
|
|
43
|
+
pin = @io_handler.prompt("Enter PIN for #{device_label(device)}: ")
|
|
44
|
+
[pin.to_s.strip]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Display a PIN code to the user
|
|
49
|
+
# @param device [String] D-Bus object path of the device
|
|
50
|
+
# @param pincode [String] PIN code to display
|
|
51
|
+
dbus_method :DisplayPinCode, 'in device:o, in pincode:s' do |device, pincode|
|
|
52
|
+
@io_handler.display("PIN for #{device_label(device)}: #{pincode}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Request a passkey (numeric) from the user
|
|
56
|
+
# @param device [String] D-Bus object path of the device
|
|
57
|
+
# @return [Integer] Passkey (0-999999)
|
|
58
|
+
dbus_method :RequestPasskey, 'in device:o, out passkey:u' do |device|
|
|
59
|
+
if @force
|
|
60
|
+
@io_handler.display("Auto-accepting passkey for #{device_label(device)}")
|
|
61
|
+
[0]
|
|
62
|
+
else
|
|
63
|
+
input = @io_handler.prompt("Enter passkey (0-999999) for #{device_label(device)}: ")
|
|
64
|
+
passkey = input.to_s.strip.to_i
|
|
65
|
+
passkey = passkey.clamp(0, 999_999)
|
|
66
|
+
[passkey]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Display a passkey with the number of digits entered so far
|
|
71
|
+
# @param device [String] D-Bus object path of the device
|
|
72
|
+
# @param passkey [Integer] Passkey being entered
|
|
73
|
+
# @param entered [Integer] Number of digits entered
|
|
74
|
+
dbus_method :DisplayPasskey, 'in device:o, in passkey:u, in entered:q' do |device, passkey, entered|
|
|
75
|
+
@io_handler.display("Passkey for #{device_label(device)}: #{format('%06d', passkey)} (#{entered} digits entered)")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Request confirmation of a passkey
|
|
79
|
+
# @param device [String] D-Bus object path of the device
|
|
80
|
+
# @param passkey [Integer] Passkey to confirm
|
|
81
|
+
dbus_method :RequestConfirmation, 'in device:o, in passkey:u' do |device, passkey|
|
|
82
|
+
if @force
|
|
83
|
+
@io_handler.display("Auto-confirming passkey #{format('%06d', passkey)} for #{device_label(device)}")
|
|
84
|
+
else
|
|
85
|
+
accepted = @io_handler.confirm("Confirm passkey #{format('%06d', passkey)} for #{device_label(device)}? [Y/n] ")
|
|
86
|
+
unless accepted
|
|
87
|
+
raise DBus.error('org.bluez.Error.Rejected'), "Passkey rejected by user for #{device_label(device)}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Request authorization for the device
|
|
93
|
+
# @param device [String] D-Bus object path of the device
|
|
94
|
+
dbus_method :RequestAuthorization, 'in device:o' do |device|
|
|
95
|
+
if @force
|
|
96
|
+
@io_handler.display("Auto-authorizing #{device_label(device)}")
|
|
97
|
+
else
|
|
98
|
+
accepted = @io_handler.confirm("Authorize #{device_label(device)}? [Y/n] ")
|
|
99
|
+
unless accepted
|
|
100
|
+
raise DBus.error('org.bluez.Error.Rejected'), "Authorization rejected by user for #{device_label(device)}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Authorize a service (auto-authorize, no prompt needed)
|
|
106
|
+
# @param device [String] D-Bus object path of the device
|
|
107
|
+
# @param uuid [String] Service UUID
|
|
108
|
+
dbus_method :AuthorizeService, 'in device:o, in uuid:s' do |device, uuid|
|
|
109
|
+
@io_handler.display("Authorizing service #{uuid} for #{device_label(device)}")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Called when a pairing request is cancelled
|
|
113
|
+
dbus_method :Cancel do
|
|
114
|
+
@io_handler.display('Pairing cancelled')
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Extract a human-readable label from a D-Bus device path
|
|
121
|
+
# @param device_path [String] D-Bus object path like /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF
|
|
122
|
+
# @return [String] MAC address or the raw path
|
|
123
|
+
def device_label(device_path)
|
|
124
|
+
if device_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
|
|
125
|
+
::Regexp.last_match(1).tr('_', ':')
|
|
126
|
+
else
|
|
127
|
+
device_path
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dbus'
|
|
4
|
+
|
|
5
|
+
module RBLE
|
|
6
|
+
module BlueZ
|
|
7
|
+
# Manages the lifecycle of a PairingAgent during pair/unpair operations.
|
|
8
|
+
#
|
|
9
|
+
# Each PairingSession creates a dedicated D-Bus bus connection, exports a
|
|
10
|
+
# PairingAgent on it, registers the agent with BlueZ's AgentManager1, and
|
|
11
|
+
# drives the Pair() call with a DBus::Main event loop so agent callbacks
|
|
12
|
+
# are processed in the same thread context.
|
|
13
|
+
#
|
|
14
|
+
# The agent MUST be on the SAME bus as RegisterAgent and Pair() calls.
|
|
15
|
+
# A dedicated bus per session avoids conflicts with the shared DBusSession.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# session = PairingSession.new(io_handler: handler, force: false, capability: "KeyboardDisplay")
|
|
19
|
+
# result = session.pair("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", timeout: 30)
|
|
20
|
+
# # => :paired or :already_paired
|
|
21
|
+
#
|
|
22
|
+
class PairingSession
|
|
23
|
+
CAPABILITY_MAP = {
|
|
24
|
+
'low' => 'NoInputNoOutput',
|
|
25
|
+
'medium' => 'DisplayYesNo',
|
|
26
|
+
'high' => 'KeyboardDisplay',
|
|
27
|
+
nil => 'KeyboardDisplay'
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# @param io_handler [#display, #prompt, #confirm] IO handler for user interaction
|
|
31
|
+
# @param force [Boolean] Skip interactive prompts (auto-accept)
|
|
32
|
+
# @param capability [String] Agent capability from CAPABILITY_MAP values
|
|
33
|
+
def initialize(io_handler:, force: false, capability: 'KeyboardDisplay')
|
|
34
|
+
@io_handler = io_handler
|
|
35
|
+
@force = force
|
|
36
|
+
@capability = capability
|
|
37
|
+
@bus = nil
|
|
38
|
+
@agent = nil
|
|
39
|
+
@main_loop = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Pair with a device by exporting an agent and calling Device1.Pair()
|
|
43
|
+
#
|
|
44
|
+
# Creates a dedicated D-Bus bus, exports the PairingAgent, registers it
|
|
45
|
+
# with AgentManager1, then calls Pair() with an event loop to process
|
|
46
|
+
# agent callbacks.
|
|
47
|
+
#
|
|
48
|
+
# @param device_path [String] D-Bus device path (e.g., /org/bluez/hci0/dev_AA_BB_...)
|
|
49
|
+
# @param timeout [Numeric] Timeout in seconds for the pairing operation
|
|
50
|
+
# @return [Symbol] :paired on success, :already_paired if already paired
|
|
51
|
+
# @raise [RBLE::AuthenticationError] if authentication fails
|
|
52
|
+
# @raise [RBLE::ConnectionFailed] if connection to device fails
|
|
53
|
+
# @raise [RBLE::TimeoutError] if pairing times out
|
|
54
|
+
# @raise [RBLE::Error] on other failures
|
|
55
|
+
def pair(device_path, timeout: 30)
|
|
56
|
+
setup_bus_and_agent
|
|
57
|
+
register_agent
|
|
58
|
+
call_pair(device_path, timeout: timeout)
|
|
59
|
+
ensure
|
|
60
|
+
cleanup
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Create dedicated D-Bus bus and export the agent
|
|
66
|
+
def setup_bus_and_agent
|
|
67
|
+
@bus = DBus::ASystemBus.new
|
|
68
|
+
@agent = PairingAgent.new(io_handler: @io_handler, force: @force)
|
|
69
|
+
@bus.object_server.export(@agent)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Register the agent with BlueZ's AgentManager1
|
|
73
|
+
def register_agent
|
|
74
|
+
bluez = @bus.service(BLUEZ_SERVICE)
|
|
75
|
+
bluez_root = bluez.object('/')
|
|
76
|
+
bluez_root.introspect
|
|
77
|
+
|
|
78
|
+
agent_manager = bluez_root[AGENT_MANAGER_INTERFACE]
|
|
79
|
+
agent_manager.RegisterAgent(PairingAgent::AGENT_PATH, @capability)
|
|
80
|
+
agent_manager.RequestDefaultAgent(PairingAgent::AGENT_PATH)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Call Device1.Pair() with an event loop for agent callback processing
|
|
84
|
+
#
|
|
85
|
+
# Uses a Thread::Queue to communicate the result from the async callback
|
|
86
|
+
# back to the calling thread. DBus::Main runs in a background thread to
|
|
87
|
+
# process agent method invocations from BlueZ.
|
|
88
|
+
#
|
|
89
|
+
# @param device_path [String] D-Bus device path
|
|
90
|
+
# @param timeout [Numeric] Timeout in seconds
|
|
91
|
+
# @return [Symbol] :paired or :already_paired
|
|
92
|
+
def call_pair(device_path, timeout: 30)
|
|
93
|
+
result_queue = Thread::Queue.new
|
|
94
|
+
|
|
95
|
+
# Introspect device to get Device1 interface
|
|
96
|
+
device_obj = @bus.service(BLUEZ_SERVICE).object(device_path)
|
|
97
|
+
device_obj.introspect
|
|
98
|
+
device_iface = device_obj[DEVICE_INTERFACE]
|
|
99
|
+
|
|
100
|
+
# Start event loop in background thread for agent callbacks
|
|
101
|
+
@main_loop = DBus::Main.new
|
|
102
|
+
@main_loop << @bus
|
|
103
|
+
|
|
104
|
+
loop_thread = Thread.new do
|
|
105
|
+
@main_loop.run
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
RBLE.logger&.debug("[RBLE] PairingSession event loop error: #{e.message}")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Call Pair() asynchronously - result delivered via callback
|
|
111
|
+
device_iface.Pair do |reply|
|
|
112
|
+
if reply.is_a?(DBus::Error)
|
|
113
|
+
result_queue.push([:error, reply])
|
|
114
|
+
else
|
|
115
|
+
result_queue.push([:success, nil])
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Wait for result with timeout
|
|
120
|
+
result = result_queue.pop(timeout: timeout)
|
|
121
|
+
|
|
122
|
+
# Stop the event loop
|
|
123
|
+
@main_loop&.quit
|
|
124
|
+
loop_thread&.join(2)
|
|
125
|
+
|
|
126
|
+
if result.nil?
|
|
127
|
+
raise RBLE::TimeoutError.new('Pair', timeout)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
status, error = result
|
|
131
|
+
|
|
132
|
+
if status == :error
|
|
133
|
+
translate_pair_error(error, device_path)
|
|
134
|
+
else
|
|
135
|
+
:paired
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Translate D-Bus errors from Pair() into RBLE exceptions
|
|
140
|
+
#
|
|
141
|
+
# @param error [DBus::Error] The D-Bus error
|
|
142
|
+
# @param device_path [String] Device path for error context
|
|
143
|
+
# @return [Symbol] :already_paired if device is already paired
|
|
144
|
+
# @raise [RBLE::AuthenticationError] on auth failures
|
|
145
|
+
# @raise [RBLE::ConnectionFailed] on connection failures
|
|
146
|
+
# @raise [RBLE::Error] on other failures
|
|
147
|
+
def translate_pair_error(error, device_path)
|
|
148
|
+
address = extract_address(device_path)
|
|
149
|
+
|
|
150
|
+
case error.name
|
|
151
|
+
when 'org.bluez.Error.AlreadyExists'
|
|
152
|
+
:already_paired
|
|
153
|
+
when 'org.bluez.Error.AuthenticationFailed',
|
|
154
|
+
'org.bluez.Error.AuthenticationCanceled',
|
|
155
|
+
'org.bluez.Error.AuthenticationRejected',
|
|
156
|
+
'org.bluez.Error.AuthenticationTimeout'
|
|
157
|
+
raise RBLE::AuthenticationError.new(address)
|
|
158
|
+
when 'org.bluez.Error.ConnectionAttemptFailed'
|
|
159
|
+
raise RBLE::ConnectionFailed.new(address, 'device not reachable (out of range?)')
|
|
160
|
+
else
|
|
161
|
+
raise RBLE::Error, "Pairing failed for #{address}: #{error.message}"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Extract MAC address from D-Bus device path
|
|
166
|
+
# @param device_path [String] D-Bus path like /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF
|
|
167
|
+
# @return [String] MAC address or 'unknown'
|
|
168
|
+
def extract_address(device_path)
|
|
169
|
+
if device_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
|
|
170
|
+
::Regexp.last_match(1).tr('_', ':')
|
|
171
|
+
else
|
|
172
|
+
'unknown'
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Clean up D-Bus resources
|
|
177
|
+
# Unregisters agent, unexports it, and closes the bus connection.
|
|
178
|
+
# All errors during cleanup are silently ignored.
|
|
179
|
+
def cleanup
|
|
180
|
+
# Stop event loop if still running
|
|
181
|
+
@main_loop&.quit rescue nil
|
|
182
|
+
|
|
183
|
+
# Unregister agent from AgentManager1
|
|
184
|
+
if @bus
|
|
185
|
+
begin
|
|
186
|
+
bluez = @bus.service(BLUEZ_SERVICE)
|
|
187
|
+
bluez_root = bluez.object('/')
|
|
188
|
+
bluez_root.introspect
|
|
189
|
+
agent_manager = bluez_root[AGENT_MANAGER_INTERFACE]
|
|
190
|
+
agent_manager.UnregisterAgent(PairingAgent::AGENT_PATH)
|
|
191
|
+
rescue StandardError
|
|
192
|
+
# Ignore errors during cleanup
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Unexport agent from bus
|
|
197
|
+
if @bus && @agent
|
|
198
|
+
begin
|
|
199
|
+
@bus.object_server.unexport(@agent)
|
|
200
|
+
rescue StandardError
|
|
201
|
+
# Ignore errors during cleanup
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Close the dedicated bus
|
|
206
|
+
@bus = nil
|
|
207
|
+
@agent = nil
|
|
208
|
+
@main_loop = nil
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBLE
|
|
4
|
+
module BlueZ
|
|
5
|
+
# Retry policy for handling transient BlueZ errors with exponential backoff.
|
|
6
|
+
#
|
|
7
|
+
# BlueZ can return org.bluez.Error.InProgress when operations are attempted
|
|
8
|
+
# too quickly or when the stack is busy. This module provides retry logic
|
|
9
|
+
# with exponential backoff to handle these cases gracefully.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# include RetryPolicy
|
|
13
|
+
#
|
|
14
|
+
# with_inprogress_retry do
|
|
15
|
+
# session.async_read_value(char_path)
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
module RetryPolicy
|
|
19
|
+
# Maximum number of retry attempts for InProgress errors
|
|
20
|
+
MAX_RETRIES = 3
|
|
21
|
+
|
|
22
|
+
# Initial delay before first retry (50ms)
|
|
23
|
+
INITIAL_DELAY = 0.05
|
|
24
|
+
|
|
25
|
+
# Maximum delay between retries (500ms)
|
|
26
|
+
MAX_DELAY = 0.5
|
|
27
|
+
|
|
28
|
+
# Execute a block with retry logic for InProgress errors
|
|
29
|
+
#
|
|
30
|
+
# @yield Block containing the operation to execute
|
|
31
|
+
# @return [Object] Result from the block
|
|
32
|
+
# @raise [OperationInProgressError] if all retries exhausted
|
|
33
|
+
# @raise Re-raises any other exception
|
|
34
|
+
def with_inprogress_retry
|
|
35
|
+
attempts = 0
|
|
36
|
+
delay = INITIAL_DELAY
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
yield
|
|
40
|
+
rescue DBus::Error => e
|
|
41
|
+
if e.name == 'org.bluez.Error.InProgress' && attempts < MAX_RETRIES
|
|
42
|
+
attempts += 1
|
|
43
|
+
RBLE.logger&.debug("[RBLE] InProgress error, retry #{attempts}/#{MAX_RETRIES} after #{(delay * 1000).round}ms")
|
|
44
|
+
sleep(delay)
|
|
45
|
+
delay = [delay * 2, MAX_DELAY].min # Exponential backoff with cap
|
|
46
|
+
retry
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Re-raise if not InProgress or retries exhausted
|
|
50
|
+
raise
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/rble/bluez.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require 'dbus'
|
|
5
|
+
rescue LoadError
|
|
6
|
+
raise LoadError,
|
|
7
|
+
"The ruby-dbus gem is required for the BlueZ backend on Linux. " \
|
|
8
|
+
"Install it with: gem install ruby-dbus"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module RBLE
|
|
12
|
+
module BlueZ
|
|
13
|
+
BLUEZ_SERVICE = 'org.bluez'
|
|
14
|
+
ADAPTER_INTERFACE = 'org.bluez.Adapter1'
|
|
15
|
+
DEVICE_INTERFACE = 'org.bluez.Device1'
|
|
16
|
+
GATT_SERVICE_INTERFACE = 'org.bluez.GattService1'
|
|
17
|
+
GATT_CHARACTERISTIC_INTERFACE = 'org.bluez.GattCharacteristic1'
|
|
18
|
+
GATT_DESCRIPTOR_INTERFACE = 'org.bluez.GattDescriptor1'
|
|
19
|
+
PROPERTIES_INTERFACE = 'org.freedesktop.DBus.Properties'
|
|
20
|
+
OBJECT_MANAGER_INTERFACE = 'org.freedesktop.DBus.ObjectManager'
|
|
21
|
+
AGENT_MANAGER_INTERFACE = 'org.bluez.AgentManager1'
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
require_relative 'bluez/dbus_connection'
|
|
26
|
+
require_relative 'bluez/adapter'
|
|
27
|
+
require_relative 'bluez/event_loop'
|
|
28
|
+
require_relative 'bluez/dbus_session'
|
|
29
|
+
require_relative 'bluez/device'
|
|
30
|
+
require_relative 'bluez/gatt_operation_queue'
|
|
31
|
+
require_relative 'bluez/retry_policy'
|
|
32
|
+
require_relative 'bluez/pairing_agent'
|
|
33
|
+
require_relative 'bluez/pairing_session'
|