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
data/lib/rble/backend.rb
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'backend/base'
|
|
4
|
+
require_relative 'errors'
|
|
5
|
+
|
|
6
|
+
module RBLE
|
|
7
|
+
module Backend
|
|
8
|
+
@backend_mutex = Mutex.new
|
|
9
|
+
@backend_symbol = nil # :bluez, :corebluetooth
|
|
10
|
+
@backend_instance = nil # The actual backend object
|
|
11
|
+
@backend_frozen = false # Locked after first BLE operation
|
|
12
|
+
|
|
13
|
+
# Returns the current backend symbol
|
|
14
|
+
# @return [Symbol] :bluez or :corebluetooth
|
|
15
|
+
def self.backend
|
|
16
|
+
@backend_mutex.synchronize do
|
|
17
|
+
ensure_backend_selected
|
|
18
|
+
@backend_symbol
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Set the backend to use (before first BLE operation)
|
|
23
|
+
# @param value [Symbol, String] Backend name (:bluez, :corebluetooth)
|
|
24
|
+
# @raise [BackendAlreadySelectedError] if backend already frozen
|
|
25
|
+
# @raise [ArgumentError] if backend is invalid for platform
|
|
26
|
+
# @raise [BackendUnavailableError] if backend is unavailable
|
|
27
|
+
def self.backend=(value)
|
|
28
|
+
@backend_mutex.synchronize do
|
|
29
|
+
if @backend_frozen
|
|
30
|
+
raise BackendAlreadySelectedError,
|
|
31
|
+
"Cannot change backend after BLE operations. Current: #{@backend_symbol}"
|
|
32
|
+
end
|
|
33
|
+
perform_backend_selection(value)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get backend information
|
|
38
|
+
# @return [Hash] { name: Symbol, version: String, capabilities: Array }
|
|
39
|
+
def self.backend_info
|
|
40
|
+
{
|
|
41
|
+
name: backend,
|
|
42
|
+
version: backend_version,
|
|
43
|
+
capabilities: backend_capabilities
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# List available backends for the current platform
|
|
48
|
+
# @return [Array<Symbol>] Available backend symbols
|
|
49
|
+
def self.available_backends
|
|
50
|
+
case RUBY_PLATFORM
|
|
51
|
+
when /darwin/
|
|
52
|
+
[:corebluetooth]
|
|
53
|
+
when /linux/
|
|
54
|
+
[:bluez]
|
|
55
|
+
else
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns the appropriate backend for the current platform (singleton)
|
|
61
|
+
# @return [Backend::Base] Platform-specific backend instance
|
|
62
|
+
# @raise [Error] if platform is not supported
|
|
63
|
+
def self.for_platform
|
|
64
|
+
backend_instance
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Reset the singleton instance (for testing)
|
|
68
|
+
# @api private
|
|
69
|
+
def self.reset!
|
|
70
|
+
@backend_mutex.synchronize do
|
|
71
|
+
@backend_instance&.shutdown if @backend_instance.respond_to?(:shutdown)
|
|
72
|
+
@backend_instance = nil
|
|
73
|
+
@backend_symbol = nil
|
|
74
|
+
@backend_frozen = false
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class << self
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Ensure a backend is selected (lazy selection)
|
|
82
|
+
# Must be called within @backend_mutex.synchronize
|
|
83
|
+
def ensure_backend_selected
|
|
84
|
+
return if @backend_symbol
|
|
85
|
+
|
|
86
|
+
# Check ENV override first (highest priority)
|
|
87
|
+
env_backend = ENV['RBLE_BACKEND']
|
|
88
|
+
if env_backend && !env_backend.empty?
|
|
89
|
+
perform_backend_selection(env_backend)
|
|
90
|
+
else
|
|
91
|
+
perform_backend_selection(platform_default)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Get the default backend for the current platform
|
|
96
|
+
# @return [Symbol] Default backend symbol
|
|
97
|
+
# @raise [Error] if platform is not supported
|
|
98
|
+
def platform_default
|
|
99
|
+
case RUBY_PLATFORM
|
|
100
|
+
when /darwin/
|
|
101
|
+
:corebluetooth
|
|
102
|
+
when /linux/
|
|
103
|
+
:bluez
|
|
104
|
+
else
|
|
105
|
+
raise Error, "Unsupported platform: #{RUBY_PLATFORM}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Perform backend selection with validation
|
|
110
|
+
# Must be called within @backend_mutex.synchronize
|
|
111
|
+
# @param value [Symbol, String] Backend name
|
|
112
|
+
def perform_backend_selection(value)
|
|
113
|
+
sym = normalize_backend(value)
|
|
114
|
+
validate_platform_compatibility!(sym)
|
|
115
|
+
validate_backend_availability!(sym)
|
|
116
|
+
@backend_symbol = sym
|
|
117
|
+
@backend_instance = nil # Lazy load
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Normalize backend value to symbol
|
|
121
|
+
# @param value [Symbol, String] Backend name
|
|
122
|
+
# @return [Symbol] Normalized backend symbol
|
|
123
|
+
def normalize_backend(value)
|
|
124
|
+
value.to_s.downcase.to_sym
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Validate backend is compatible with current platform
|
|
128
|
+
# @param sym [Symbol] Backend symbol
|
|
129
|
+
# @raise [ArgumentError] if backend is invalid for platform
|
|
130
|
+
def validate_platform_compatibility!(sym)
|
|
131
|
+
available = available_backends
|
|
132
|
+
return if available.include?(sym)
|
|
133
|
+
|
|
134
|
+
platform_name = case RUBY_PLATFORM
|
|
135
|
+
when /darwin/ then 'macOS'
|
|
136
|
+
when /linux/ then 'Linux'
|
|
137
|
+
else RUBY_PLATFORM
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
raise ArgumentError,
|
|
141
|
+
"Backend :#{sym} not available on #{platform_name}. Available: #{available.map { |b| ":#{b}" }.join(', ')}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Validate backend can be used
|
|
145
|
+
# @param sym [Symbol] Backend symbol
|
|
146
|
+
# @raise [BackendUnavailableError] if backend is unavailable
|
|
147
|
+
def validate_backend_availability!(sym)
|
|
148
|
+
# :bluez and :corebluetooth validated at use time
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Get or create backend instance
|
|
152
|
+
# @return [Backend::Base] Backend instance
|
|
153
|
+
def backend_instance
|
|
154
|
+
@backend_mutex.synchronize do
|
|
155
|
+
ensure_backend_selected
|
|
156
|
+
@backend_instance ||= load_backend(@backend_symbol)
|
|
157
|
+
@backend_frozen = true
|
|
158
|
+
@backend_instance
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Create backend instance
|
|
163
|
+
# @param sym [Symbol] Backend symbol
|
|
164
|
+
# @return [Backend::Base] Backend instance
|
|
165
|
+
def load_backend(sym)
|
|
166
|
+
case sym
|
|
167
|
+
when :bluez
|
|
168
|
+
require_relative 'backend/bluez'
|
|
169
|
+
BlueZ.new
|
|
170
|
+
when :corebluetooth
|
|
171
|
+
require_relative 'backend/corebluetooth'
|
|
172
|
+
CoreBluetooth.new
|
|
173
|
+
else
|
|
174
|
+
raise Error, "Unknown backend: #{sym}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Get backend version
|
|
179
|
+
# @return [String] Version string or 'unknown'
|
|
180
|
+
def backend_version
|
|
181
|
+
inst = backend_instance
|
|
182
|
+
inst.respond_to?(:version) ? inst.version : 'unknown'
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Get backend capabilities
|
|
186
|
+
# @return [Array] Capabilities array or empty array
|
|
187
|
+
def backend_capabilities
|
|
188
|
+
inst = backend_instance
|
|
189
|
+
inst.respond_to?(:capabilities) ? inst.capabilities : []
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBLE
|
|
4
|
+
module BlueZ
|
|
5
|
+
# Wraps a BlueZ Bluetooth adapter (org.bluez.Adapter1)
|
|
6
|
+
class Adapter
|
|
7
|
+
attr_reader :path, :name
|
|
8
|
+
|
|
9
|
+
# Create an Adapter from a DBusSession
|
|
10
|
+
# @param session [DBusSession] Active D-Bus session
|
|
11
|
+
# @param path [String] D-Bus object path (e.g., "/org/bluez/hci0")
|
|
12
|
+
# @return [Adapter]
|
|
13
|
+
def self.new_from_session(session, path)
|
|
14
|
+
adapter = allocate
|
|
15
|
+
adapter.instance_variable_set(:@session, session)
|
|
16
|
+
adapter.instance_variable_set(:@path, path)
|
|
17
|
+
adapter.instance_variable_set(:@name, path.split('/').last)
|
|
18
|
+
# Async introspect for non-blocking setup
|
|
19
|
+
proxy = session.async_introspect(path, timeout: 5)
|
|
20
|
+
adapter.instance_variable_set(:@object, proxy)
|
|
21
|
+
adapter.instance_variable_set(:@adapter_iface, proxy[ADAPTER_INTERFACE])
|
|
22
|
+
adapter.instance_variable_set(:@properties_iface, proxy[PROPERTIES_INTERFACE])
|
|
23
|
+
adapter
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Get adapter MAC address
|
|
27
|
+
# @return [String]
|
|
28
|
+
def address
|
|
29
|
+
@session.async_get_property(@path, ADAPTER_INTERFACE, 'Address', timeout: 5)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Check if adapter is powered on
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def powered?
|
|
35
|
+
@session.async_get_property(@path, ADAPTER_INTERFACE, 'Powered', timeout: 5)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check if discovery is in progress
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def discovering?
|
|
41
|
+
@session.async_get_property(@path, ADAPTER_INTERFACE, 'Discovering', timeout: 5)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Check if adapter is discoverable
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def discoverable?
|
|
47
|
+
@session.async_get_property(@path, ADAPTER_INTERFACE, 'Discoverable', timeout: 5)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if adapter is pairable
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def pairable?
|
|
53
|
+
@session.async_get_property(@path, ADAPTER_INTERFACE, 'Pairable', timeout: 5)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get adapter alias (friendly name)
|
|
57
|
+
# @return [String]
|
|
58
|
+
def alias_name
|
|
59
|
+
@session.async_get_property(@path, ADAPTER_INTERFACE, 'Alias', timeout: 5)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Set adapter power state
|
|
63
|
+
# @param value [Boolean] true to power on, false to power off
|
|
64
|
+
def set_powered(value)
|
|
65
|
+
@session.async_set_property(@path, ADAPTER_INTERFACE, 'Powered', value, timeout: 5)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Set adapter discoverable mode
|
|
69
|
+
# @param value [Boolean] true to enable, false to disable
|
|
70
|
+
def set_discoverable(value)
|
|
71
|
+
@session.async_set_property(@path, ADAPTER_INTERFACE, 'Discoverable', value, timeout: 5)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Set adapter pairable mode
|
|
75
|
+
# @param value [Boolean] true to enable, false to disable
|
|
76
|
+
def set_pairable(value)
|
|
77
|
+
@session.async_set_property(@path, ADAPTER_INTERFACE, 'Pairable', value, timeout: 5)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Set adapter alias (friendly name)
|
|
81
|
+
# @param name [String] New alias
|
|
82
|
+
def set_alias(name)
|
|
83
|
+
@session.async_set_property(@path, ADAPTER_INTERFACE, 'Alias', name, timeout: 5)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Set discovery filter before starting scan
|
|
87
|
+
# @param service_uuids [Array<String>, nil] Filter by these UUIDs
|
|
88
|
+
# @param allow_duplicates [Boolean] Receive every advertisement
|
|
89
|
+
# @param rssi [Integer, nil] Minimum RSSI value
|
|
90
|
+
# @param pathloss [Integer, nil] Maximum path loss
|
|
91
|
+
def set_discovery_filter(service_uuids: nil, allow_duplicates: false, rssi: nil, pathloss: nil)
|
|
92
|
+
# Build filter options for async method
|
|
93
|
+
filter_options = {
|
|
94
|
+
transport: 'le',
|
|
95
|
+
duplicate_data: allow_duplicates
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Add optional filters
|
|
99
|
+
if service_uuids && !service_uuids.empty?
|
|
100
|
+
filter_options[:uuids] = service_uuids.map { |uuid| normalize_uuid(uuid) }
|
|
101
|
+
end
|
|
102
|
+
filter_options[:rssi] = rssi if rssi
|
|
103
|
+
filter_options[:pathloss] = pathloss if pathloss
|
|
104
|
+
|
|
105
|
+
# Store filter for application in start_discovery via async_start_discovery
|
|
106
|
+
@pending_filter = filter_options
|
|
107
|
+
rescue DBus::Error => e
|
|
108
|
+
raise ScanError, "Failed to set discovery filter: #{e.message}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Start BLE discovery
|
|
112
|
+
def start_discovery
|
|
113
|
+
# Async path: idempotent, handles powered? check internally
|
|
114
|
+
filter = @pending_filter || { transport: 'le' }
|
|
115
|
+
@session.async_start_discovery(@path, filter: filter, timeout: 10)
|
|
116
|
+
@pending_filter = nil
|
|
117
|
+
rescue DBus::Error => e
|
|
118
|
+
if e.message.include?('InProgress')
|
|
119
|
+
raise ScanInProgressError
|
|
120
|
+
elsif e.message.include?('NotReady') || e.message.include?('NotPowered')
|
|
121
|
+
raise AdapterDisabledError.new(@name)
|
|
122
|
+
end
|
|
123
|
+
raise ScanError, "Failed to start discovery: #{e.message}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Stop BLE discovery
|
|
127
|
+
def stop_discovery
|
|
128
|
+
# Async path: idempotent, no need to check discovering?
|
|
129
|
+
@session.async_stop_discovery(@path, timeout: 10)
|
|
130
|
+
rescue DBus::Error => e
|
|
131
|
+
# Ignore "not discovering" errors during cleanup
|
|
132
|
+
unless e.message.include?('NotAuthorized') || e.message.include?('NotDiscovering')
|
|
133
|
+
raise ScanError, "Failed to stop discovery: #{e.message}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Get adapter info as hash
|
|
138
|
+
# @return [Hash] with adapter properties
|
|
139
|
+
def to_h
|
|
140
|
+
{
|
|
141
|
+
name: @name,
|
|
142
|
+
address: address,
|
|
143
|
+
powered: powered?,
|
|
144
|
+
discoverable: discoverable?,
|
|
145
|
+
pairable: pairable?,
|
|
146
|
+
alias: alias_name,
|
|
147
|
+
discovering: discovering?
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# Normalize UUID to BlueZ format (lowercase, with hyphens for 128-bit)
|
|
154
|
+
def normalize_uuid(uuid)
|
|
155
|
+
uuid = uuid.to_s.downcase.delete('-')
|
|
156
|
+
|
|
157
|
+
# 16-bit UUIDs (4 hex chars) stay short
|
|
158
|
+
return uuid if uuid.length == 4
|
|
159
|
+
|
|
160
|
+
# 32-bit and 128-bit get full format with hyphens
|
|
161
|
+
if uuid.length == 32
|
|
162
|
+
"#{uuid[0..7]}-#{uuid[8..11]}-#{uuid[12..15]}-#{uuid[16..19]}-#{uuid[20..31]}"
|
|
163
|
+
else
|
|
164
|
+
uuid
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dbus'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module RBLE
|
|
7
|
+
module BlueZ
|
|
8
|
+
# Provides async D-Bus call wrapper with Queue-based result delivery.
|
|
9
|
+
# Use this to make D-Bus method calls without blocking the event loop.
|
|
10
|
+
#
|
|
11
|
+
# This module is the foundation for the v0.4.0 async architecture. By routing
|
|
12
|
+
# D-Bus method call results through Thread::Queue instead of blocking on the
|
|
13
|
+
# D-Bus socket, we eliminate the conflict between DBus::Main and synchronous calls.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# include AsyncCall
|
|
17
|
+
#
|
|
18
|
+
# result = async_call('ReadValue', timeout: 5) do |queue, request_id, cancelled|
|
|
19
|
+
# proxy.ReadValue({}) do |reply, *params|
|
|
20
|
+
# next if cancelled[0] # Discard late callback
|
|
21
|
+
# if reply.is_a?(DBus::Error)
|
|
22
|
+
# queue.push([reply, nil])
|
|
23
|
+
# else
|
|
24
|
+
# queue.push([reply, params])
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
module AsyncCall
|
|
30
|
+
# Count of late callbacks received after timeout (for metrics)
|
|
31
|
+
attr_reader :late_callback_count
|
|
32
|
+
# Execute a D-Bus call asynchronously with Queue-based result delivery
|
|
33
|
+
#
|
|
34
|
+
# @param operation [String] Operation name for logging/errors
|
|
35
|
+
# @param timeout [Numeric] Timeout in seconds (default: 5)
|
|
36
|
+
# @yield [queue, request_id, cancelled] Block that performs async D-Bus call
|
|
37
|
+
# @yieldparam queue [Thread::Queue] Push [reply, params] to deliver result
|
|
38
|
+
# @yieldparam request_id [String] Request ID for log correlation
|
|
39
|
+
# @yieldparam cancelled [Array<Boolean>] Mutable container - check cancelled[0] before pushing
|
|
40
|
+
# @return [Array, nil] Method call result params
|
|
41
|
+
# @raise [TimeoutError] if timeout exceeded
|
|
42
|
+
# @raise [SessionClosedError] if session closed while operation pending
|
|
43
|
+
# @raise [DBus::Error] propagated from D-Bus (for caller to translate)
|
|
44
|
+
def async_call(operation, timeout: 5)
|
|
45
|
+
queue = Thread::Queue.new
|
|
46
|
+
request_id = SecureRandom.hex(4)
|
|
47
|
+
start_time = Time.now
|
|
48
|
+
cancelled = [false] # Mutable container for cancellation state
|
|
49
|
+
|
|
50
|
+
# Track queue for shutdown notification (if @pending_queues exists)
|
|
51
|
+
@pending_queues&.push(queue)
|
|
52
|
+
|
|
53
|
+
RBLE.logger&.debug("[RBLE] #{request_id} Starting #{operation}")
|
|
54
|
+
warn " [RBLE] #{request_id} #{operation} timeout=#{timeout.round(2)}s" if RBLE.trace
|
|
55
|
+
|
|
56
|
+
yield(queue, request_id, cancelled)
|
|
57
|
+
|
|
58
|
+
result = queue.pop(timeout: timeout)
|
|
59
|
+
elapsed = Time.now - start_time
|
|
60
|
+
warn " [RBLE] #{request_id} #{operation} -> #{result.nil? ? 'TIMEOUT' : 'ok'} (#{elapsed.round(2)}s)" if RBLE.trace
|
|
61
|
+
|
|
62
|
+
if result.nil?
|
|
63
|
+
cancelled[0] = true
|
|
64
|
+
@late_callback_count = (@late_callback_count || 0) + 1
|
|
65
|
+
RBLE.logger&.warn("[RBLE] #{request_id} #{operation} timed out after #{elapsed.round(3)}s (late callbacks: #{@late_callback_count})")
|
|
66
|
+
raise TimeoutError.new(operation, timeout)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Check for session close marker
|
|
70
|
+
if result.is_a?(Array) && result.first == :session_closed
|
|
71
|
+
raise SessionClosedError.new
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
RBLE.logger&.debug("[RBLE] #{request_id} #{operation} completed in #{elapsed.round(3)}s")
|
|
75
|
+
|
|
76
|
+
reply, params = result
|
|
77
|
+
raise reply if reply.is_a?(DBus::Error)
|
|
78
|
+
|
|
79
|
+
params
|
|
80
|
+
ensure
|
|
81
|
+
@pending_queues&.delete(queue)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|