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,653 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RBLE
|
|
7
|
+
module Backend
|
|
8
|
+
# CoreBluetooth backend for macOS BLE operations via subprocess
|
|
9
|
+
class CoreBluetooth < Base
|
|
10
|
+
HELPER_PATH = File.expand_path('../../../ext/macos_ble/.build/release/RBLEHelper', __dir__)
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@stdin = nil
|
|
14
|
+
@stdout = nil
|
|
15
|
+
@stderr = nil
|
|
16
|
+
@wait_thread = nil
|
|
17
|
+
@request_id = 0
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
@scanning = false
|
|
20
|
+
@scan_callback = nil
|
|
21
|
+
@reader_thread = nil
|
|
22
|
+
@event_queue = Queue.new
|
|
23
|
+
|
|
24
|
+
# Separate queue for responses (id present) vs async events (method present)
|
|
25
|
+
@response_queue = Queue.new
|
|
26
|
+
|
|
27
|
+
# Connection tracking
|
|
28
|
+
@connected_devices = {} # device_uuid => true
|
|
29
|
+
@device_services = {} # device_uuid => [service_data, ...]
|
|
30
|
+
@connection_objects = {} # device_uuid => Connection instance
|
|
31
|
+
|
|
32
|
+
# Subscription tracking
|
|
33
|
+
@subscriptions = {} # char_identifier => callback
|
|
34
|
+
|
|
35
|
+
# Background event processor for async events (disconnect, notifications)
|
|
36
|
+
@event_processor_thread = nil
|
|
37
|
+
@event_processor_running = false
|
|
38
|
+
|
|
39
|
+
# Thread safety: protects shared state accessed from multiple threads
|
|
40
|
+
# (event_processor_thread and user thread)
|
|
41
|
+
@state_mutex = Mutex.new
|
|
42
|
+
|
|
43
|
+
# Best-effort cleanup on process exit
|
|
44
|
+
at_exit { cleanup_all_connections }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Start the subprocess if not running
|
|
48
|
+
def ensure_subprocess
|
|
49
|
+
return if @wait_thread&.alive?
|
|
50
|
+
|
|
51
|
+
unless File.exist?(HELPER_PATH)
|
|
52
|
+
raise SubprocessError, <<~MSG.strip
|
|
53
|
+
macOS BLE helper not found at #{HELPER_PATH}
|
|
54
|
+
|
|
55
|
+
The helper should build automatically during gem install. To build manually:
|
|
56
|
+
cd #{File.dirname(HELPER_PATH).sub('/.build/release', '')}
|
|
57
|
+
swift build -c release
|
|
58
|
+
|
|
59
|
+
Ensure Xcode Command Line Tools are installed:
|
|
60
|
+
xcode-select --install
|
|
61
|
+
MSG
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(HELPER_PATH)
|
|
65
|
+
|
|
66
|
+
# Start reader thread for async events
|
|
67
|
+
start_reader_thread
|
|
68
|
+
|
|
69
|
+
# Start background event processor
|
|
70
|
+
start_event_processor
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Send request and wait for response
|
|
74
|
+
def send_request(method, params = nil, timeout: 30)
|
|
75
|
+
ensure_subprocess
|
|
76
|
+
raise SubprocessError, 'Subprocess not running' unless @wait_thread&.alive?
|
|
77
|
+
|
|
78
|
+
request_id = nil
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
@request_id += 1
|
|
81
|
+
request_id = @request_id
|
|
82
|
+
request = { id: request_id, method: method }
|
|
83
|
+
request[:params] = params if params
|
|
84
|
+
|
|
85
|
+
@stdin.puts(JSON.generate(request))
|
|
86
|
+
@stdin.flush
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Wait for response in response queue (async events handled by event_processor_thread)
|
|
90
|
+
deadline = Time.now + timeout
|
|
91
|
+
loop do
|
|
92
|
+
remaining = deadline - Time.now
|
|
93
|
+
raise ConnectionTimeoutError, timeout if remaining <= 0
|
|
94
|
+
|
|
95
|
+
response = @response_queue.pop(timeout: remaining)
|
|
96
|
+
raise ConnectionTimeoutError, timeout if response.nil? # timeout expired
|
|
97
|
+
|
|
98
|
+
if response[:id] == request_id
|
|
99
|
+
handle_response_error(response) if response[:error]
|
|
100
|
+
return response[:result]
|
|
101
|
+
else
|
|
102
|
+
# Not our response, put it back (shouldn't happen often with single-threaded requests)
|
|
103
|
+
@response_queue.push(response)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def shutdown
|
|
109
|
+
@event_processor_running = false
|
|
110
|
+
@event_processor_thread&.kill
|
|
111
|
+
@reader_thread&.kill
|
|
112
|
+
@stdin&.close
|
|
113
|
+
@stdout&.close
|
|
114
|
+
@stderr&.close
|
|
115
|
+
@wait_thread&.kill
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Backend::Base implementations
|
|
119
|
+
|
|
120
|
+
def start_scan(service_uuids: nil, allow_duplicates: false, adapter: nil, active: true, &block)
|
|
121
|
+
@state_mutex.synchronize do
|
|
122
|
+
raise ScanInProgressError if @scanning
|
|
123
|
+
@scanning = true
|
|
124
|
+
end
|
|
125
|
+
raise ArgumentError, 'Block required for scan callback' unless block_given?
|
|
126
|
+
|
|
127
|
+
unless active
|
|
128
|
+
RBLE.rble_warn "passive scanning (active: false) is not explicitly supported on macOS. " \
|
|
129
|
+
"CoreBluetooth does not expose active/passive scan control. " \
|
|
130
|
+
"Scanning will proceed normally."
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
if adapter
|
|
134
|
+
RBLE.rble_warn "macOS does not support adapter selection. The default adapter will be used."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
@scan_callback = block
|
|
138
|
+
|
|
139
|
+
params = { allow_duplicates: allow_duplicates }
|
|
140
|
+
params[:service_uuids] = service_uuids if service_uuids
|
|
141
|
+
|
|
142
|
+
begin
|
|
143
|
+
send_request('scan_start', params)
|
|
144
|
+
rescue StandardError
|
|
145
|
+
@state_mutex.synchronize do
|
|
146
|
+
@scanning = false
|
|
147
|
+
@scan_callback = nil
|
|
148
|
+
end
|
|
149
|
+
raise
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def stop_scan
|
|
154
|
+
@state_mutex.synchronize { return unless @scanning }
|
|
155
|
+
|
|
156
|
+
begin
|
|
157
|
+
send_request('scan_stop')
|
|
158
|
+
ensure
|
|
159
|
+
@state_mutex.synchronize do
|
|
160
|
+
@scanning = false
|
|
161
|
+
@scan_callback = nil
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def scanning?
|
|
167
|
+
@state_mutex.synchronize { @scanning }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def adapters
|
|
171
|
+
result = send_request('adapters')
|
|
172
|
+
result['adapters'].map do |a|
|
|
173
|
+
{
|
|
174
|
+
name: a['name'],
|
|
175
|
+
address: nil, # macOS doesn't expose adapter MAC
|
|
176
|
+
powered: a['powered']
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def process_events(timeout: nil)
|
|
182
|
+
deadline = timeout ? Time.now + timeout : nil
|
|
183
|
+
|
|
184
|
+
loop do
|
|
185
|
+
remaining = deadline ? [deadline - Time.now, 0].max : 0.1
|
|
186
|
+
break if deadline && remaining <= 0
|
|
187
|
+
|
|
188
|
+
begin
|
|
189
|
+
event = @event_queue.pop(true)
|
|
190
|
+
handle_async_event(event)
|
|
191
|
+
rescue ThreadError
|
|
192
|
+
sleep [remaining, 0.1].min
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
break if deadline && Time.now >= deadline
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
false # Not a clean shutdown
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Connect to a BLE device
|
|
202
|
+
# @param device_identifier [String] Device UUID (from scanning)
|
|
203
|
+
# @param timeout [Numeric] Connection timeout in seconds
|
|
204
|
+
# @return [Boolean] true on successful connection
|
|
205
|
+
# @raise [AlreadyConnectedError] if already connected
|
|
206
|
+
# @raise [ConnectionTimeoutError] if connection times out
|
|
207
|
+
def connect_device(device_identifier, timeout: 30)
|
|
208
|
+
# Check if already connected (thread-safe)
|
|
209
|
+
@state_mutex.synchronize do
|
|
210
|
+
raise AlreadyConnectedError if @connected_devices.key?(device_identifier)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
send_request('connect', {
|
|
214
|
+
uuid: device_identifier,
|
|
215
|
+
timeout: timeout
|
|
216
|
+
}, timeout: timeout + 5) # Extra buffer for subprocess
|
|
217
|
+
|
|
218
|
+
@state_mutex.synchronize { @connected_devices[device_identifier] = true }
|
|
219
|
+
true
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Disconnect from a BLE device
|
|
223
|
+
# @param device_identifier [String] Device UUID
|
|
224
|
+
# @return [void]
|
|
225
|
+
def disconnect_device(device_identifier)
|
|
226
|
+
@state_mutex.synchronize do
|
|
227
|
+
@connected_devices.delete(device_identifier)
|
|
228
|
+
@device_services.delete(device_identifier)
|
|
229
|
+
@subscriptions.delete_if { |char_id, _| char_id.start_with?(device_identifier) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
begin
|
|
233
|
+
send_request('disconnect', { uuid: device_identifier }, timeout: 5)
|
|
234
|
+
rescue StandardError
|
|
235
|
+
# Ignore errors during cleanup
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Discover GATT services on a connected device
|
|
240
|
+
# @param device_identifier [String] Device UUID
|
|
241
|
+
# @param timeout [Numeric] Discovery timeout in seconds
|
|
242
|
+
# @return [Array<Hash>] Service data with characteristics
|
|
243
|
+
# @raise [NotConnectedError] if not connected
|
|
244
|
+
# @raise [ServiceDiscoveryError] if discovery fails
|
|
245
|
+
def discover_services(device_identifier, connection: nil, timeout: 30)
|
|
246
|
+
connected, cached_services = @state_mutex.synchronize do
|
|
247
|
+
[@connected_devices.key?(device_identifier), @device_services[device_identifier]]
|
|
248
|
+
end
|
|
249
|
+
raise NotConnectedError unless connected
|
|
250
|
+
|
|
251
|
+
# Return cached services if available
|
|
252
|
+
return cached_services if cached_services
|
|
253
|
+
|
|
254
|
+
result = send_request('discover_services', {
|
|
255
|
+
uuid: device_identifier,
|
|
256
|
+
timeout: timeout
|
|
257
|
+
}, timeout: timeout + 5)
|
|
258
|
+
|
|
259
|
+
services = build_services_from_result(result['services'], device_identifier)
|
|
260
|
+
@state_mutex.synchronize { @device_services[device_identifier] = services }
|
|
261
|
+
services
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def device_path_for_address(address, adapter: nil)
|
|
265
|
+
# On macOS, address IS the UUID - just return it
|
|
266
|
+
# Validate it looks like a UUID
|
|
267
|
+
unless address =~ /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i
|
|
268
|
+
raise ConnectionError, "Invalid macOS device identifier '#{address}'. " \
|
|
269
|
+
"On macOS, use the UUID from scanning (not MAC address)."
|
|
270
|
+
end
|
|
271
|
+
address
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Register a Connection object for disconnect monitoring
|
|
275
|
+
# @param device_identifier [String] Device UUID
|
|
276
|
+
# @param connection [Connection] The Connection instance
|
|
277
|
+
# @return [void]
|
|
278
|
+
def register_connection(device_identifier, connection)
|
|
279
|
+
@state_mutex.synchronize { @connection_objects[device_identifier] = connection }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Unregister a Connection object from disconnect monitoring
|
|
283
|
+
# @param device_identifier [String] Device UUID
|
|
284
|
+
# @return [void]
|
|
285
|
+
def unregister_connection(device_identifier)
|
|
286
|
+
@state_mutex.synchronize { @connection_objects.delete(device_identifier) }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Read a characteristic value
|
|
290
|
+
# @param char_identifier [String] Format: "device_uuid:service_uuid:char_uuid"
|
|
291
|
+
# @param timeout [Numeric] Read timeout in seconds
|
|
292
|
+
# @return [String] Binary string (ASCII-8BIT encoding)
|
|
293
|
+
# @raise [ReadError] if read fails
|
|
294
|
+
def read_characteristic(char_identifier, connection: nil, timeout: 30)
|
|
295
|
+
device_uuid, service_uuid, char_uuid = parse_char_identifier(char_identifier)
|
|
296
|
+
|
|
297
|
+
result = send_request('read_characteristic', {
|
|
298
|
+
device_uuid: device_uuid,
|
|
299
|
+
service_uuid: service_uuid,
|
|
300
|
+
char_uuid: char_uuid,
|
|
301
|
+
timeout: timeout
|
|
302
|
+
}, timeout: timeout + 5)
|
|
303
|
+
|
|
304
|
+
# Convert byte array to binary string
|
|
305
|
+
(result['value'] || []).map(&:to_i).pack('C*')
|
|
306
|
+
rescue RBLE::Error
|
|
307
|
+
raise
|
|
308
|
+
rescue StandardError => e
|
|
309
|
+
raise ReadError, translate_error(e)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Write a value to a characteristic
|
|
313
|
+
# @param char_identifier [String] Format: "device_uuid:service_uuid:char_uuid"
|
|
314
|
+
# @param data [String, Array<Integer>] Data to write
|
|
315
|
+
# @param response [Boolean] Wait for write response
|
|
316
|
+
# @param timeout [Numeric] Write timeout in seconds
|
|
317
|
+
# @return [Boolean] true on success
|
|
318
|
+
# @raise [WriteError] if write fails
|
|
319
|
+
def write_characteristic(char_identifier, data, connection: nil, response: true, timeout: 30)
|
|
320
|
+
device_uuid, service_uuid, char_uuid = parse_char_identifier(char_identifier)
|
|
321
|
+
|
|
322
|
+
# Convert string to bytes array if needed
|
|
323
|
+
bytes = data.is_a?(String) ? data.bytes : data
|
|
324
|
+
|
|
325
|
+
send_request('write_characteristic', {
|
|
326
|
+
device_uuid: device_uuid,
|
|
327
|
+
service_uuid: service_uuid,
|
|
328
|
+
char_uuid: char_uuid,
|
|
329
|
+
value: bytes,
|
|
330
|
+
response: response,
|
|
331
|
+
timeout: timeout
|
|
332
|
+
}, timeout: timeout + 5)
|
|
333
|
+
|
|
334
|
+
true
|
|
335
|
+
rescue RBLE::Error
|
|
336
|
+
raise
|
|
337
|
+
rescue StandardError => e
|
|
338
|
+
raise WriteError, translate_error(e)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Subscribe to characteristic notifications
|
|
342
|
+
# @param char_identifier [String] Format: "device_uuid:service_uuid:char_uuid"
|
|
343
|
+
# @yield [String] Called with value (binary string) on each notification
|
|
344
|
+
# @return [Boolean] true on success
|
|
345
|
+
# @raise [NotifyError] if subscription fails
|
|
346
|
+
def subscribe_characteristic(char_identifier, connection: nil, &callback)
|
|
347
|
+
already_subscribed = @state_mutex.synchronize { @subscriptions.key?(char_identifier) }
|
|
348
|
+
return true if already_subscribed
|
|
349
|
+
|
|
350
|
+
device_uuid, service_uuid, char_uuid = parse_char_identifier(char_identifier)
|
|
351
|
+
|
|
352
|
+
send_request('subscribe', {
|
|
353
|
+
device_uuid: device_uuid,
|
|
354
|
+
service_uuid: service_uuid,
|
|
355
|
+
char_uuid: char_uuid
|
|
356
|
+
}, timeout: 30)
|
|
357
|
+
|
|
358
|
+
@state_mutex.synchronize { @subscriptions[char_identifier] = callback }
|
|
359
|
+
true
|
|
360
|
+
rescue RBLE::Error
|
|
361
|
+
raise
|
|
362
|
+
rescue StandardError => e
|
|
363
|
+
raise NotifyError, translate_error(e)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Unsubscribe from characteristic notifications
|
|
367
|
+
# @param char_identifier [String] Format: "device_uuid:service_uuid:char_uuid"
|
|
368
|
+
# @return [Boolean] true on success
|
|
369
|
+
def unsubscribe_characteristic(char_identifier, connection: nil)
|
|
370
|
+
callback = @state_mutex.synchronize { @subscriptions.delete(char_identifier) }
|
|
371
|
+
return true unless callback
|
|
372
|
+
|
|
373
|
+
device_uuid, service_uuid, char_uuid = parse_char_identifier(char_identifier)
|
|
374
|
+
|
|
375
|
+
begin
|
|
376
|
+
send_request('unsubscribe', {
|
|
377
|
+
device_uuid: device_uuid,
|
|
378
|
+
service_uuid: service_uuid,
|
|
379
|
+
char_uuid: char_uuid
|
|
380
|
+
}, timeout: 5)
|
|
381
|
+
rescue StandardError
|
|
382
|
+
# Ignore errors during cleanup
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
true
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Get active subscriptions for a connection
|
|
389
|
+
# @param connection [Connection] Connection to query
|
|
390
|
+
# @return [Array<Hash>] Subscription info hashes with :uuid and :path
|
|
391
|
+
def subscriptions_for_connection(connection)
|
|
392
|
+
device_uuid = connection.address
|
|
393
|
+
@state_mutex.synchronize do
|
|
394
|
+
@subscriptions.select { |path, _| path.start_with?(device_uuid) }
|
|
395
|
+
.map { |path, _callback| { path: path, uuid: path.split(':').last } }
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
private
|
|
400
|
+
|
|
401
|
+
# Best-effort disconnect of all tracked connections during process exit
|
|
402
|
+
# @return [void]
|
|
403
|
+
def cleanup_all_connections
|
|
404
|
+
connections = @state_mutex.synchronize { @connection_objects.values.dup }
|
|
405
|
+
connections.each do |conn|
|
|
406
|
+
conn.disconnect if conn.connected?
|
|
407
|
+
rescue StandardError
|
|
408
|
+
# best-effort cleanup during exit
|
|
409
|
+
end
|
|
410
|
+
shutdown if connections.any?
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def start_reader_thread
|
|
414
|
+
@reader_thread = Thread.new do
|
|
415
|
+
while (line = @stdout.gets)
|
|
416
|
+
begin
|
|
417
|
+
event = JSON.parse(line, symbolize_names: false)
|
|
418
|
+
# Normalize keys for Ruby
|
|
419
|
+
event = event.transform_keys(&:to_sym) if event.is_a?(Hash)
|
|
420
|
+
|
|
421
|
+
# Route to appropriate queue based on whether it's a response or async event
|
|
422
|
+
if event[:id]
|
|
423
|
+
# Response to a request
|
|
424
|
+
@response_queue.push(event)
|
|
425
|
+
else
|
|
426
|
+
# Async event (device_discovered, notification, disconnected, etc.)
|
|
427
|
+
@event_queue.push(event)
|
|
428
|
+
end
|
|
429
|
+
rescue JSON::ParserError
|
|
430
|
+
# Log to stderr, don't crash
|
|
431
|
+
warn "[RBLE] Invalid JSON from subprocess: #{line}"
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
rescue IOError
|
|
435
|
+
# Subprocess closed
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Start background thread to process async events
|
|
440
|
+
def start_event_processor
|
|
441
|
+
return if @event_processor_thread&.alive?
|
|
442
|
+
|
|
443
|
+
@event_processor_running = true
|
|
444
|
+
@event_processor_thread = Thread.new do
|
|
445
|
+
while @event_processor_running
|
|
446
|
+
begin
|
|
447
|
+
# Block with timeout so we can check @event_processor_running
|
|
448
|
+
event = @event_queue.pop(true) rescue nil
|
|
449
|
+
handle_async_event(event) if event
|
|
450
|
+
rescue LocalJumpError
|
|
451
|
+
# Expected when user's callback uses `break` to exit early from scanning
|
|
452
|
+
# This is normal behavior, not an error
|
|
453
|
+
rescue StandardError => e
|
|
454
|
+
warn "[RBLE] Event processor error: #{e.message}"
|
|
455
|
+
end
|
|
456
|
+
sleep 0.01 # Small sleep to prevent CPU spinning
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def handle_async_event(event)
|
|
462
|
+
case event[:method] || event['method']
|
|
463
|
+
when 'device_discovered'
|
|
464
|
+
handle_device_discovered(event[:params] || event['params'])
|
|
465
|
+
when 'notification'
|
|
466
|
+
handle_notification(event[:params] || event['params'])
|
|
467
|
+
when 'disconnected'
|
|
468
|
+
handle_disconnected(event[:params] || event['params'])
|
|
469
|
+
when 'state_changed'
|
|
470
|
+
# Could notify app of BT state change
|
|
471
|
+
when 'connected'
|
|
472
|
+
# Connection state events (handled via request/response)
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def handle_device_discovered(params)
|
|
477
|
+
return unless @scan_callback
|
|
478
|
+
|
|
479
|
+
# Build Device from params
|
|
480
|
+
device = Device.new(
|
|
481
|
+
address: params['uuid'], # On macOS, UUID is the address
|
|
482
|
+
name: params['name'],
|
|
483
|
+
rssi: params['rssi'],
|
|
484
|
+
manufacturer_data: parse_manufacturer_data(params['manufacturer_data']),
|
|
485
|
+
manufacturer_data_raw: parse_manufacturer_data_raw(params['manufacturer_data']),
|
|
486
|
+
service_data: parse_service_data(params['service_data']),
|
|
487
|
+
service_uuids: params['service_uuids'] || [],
|
|
488
|
+
tx_power: params['tx_power'],
|
|
489
|
+
address_type: nil # CoreBluetooth doesn't expose address type
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
@scan_callback.call(device)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def parse_manufacturer_data(data)
|
|
496
|
+
return {} unless data.is_a?(Hash)
|
|
497
|
+
|
|
498
|
+
company_id = data['company_id']
|
|
499
|
+
bytes = data['data']
|
|
500
|
+
return {} unless company_id && bytes
|
|
501
|
+
|
|
502
|
+
{ company_id => bytes }
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def parse_manufacturer_data_raw(data)
|
|
506
|
+
return {} unless data.is_a?(Hash)
|
|
507
|
+
|
|
508
|
+
company_id = data['company_id']
|
|
509
|
+
bytes = data['data']
|
|
510
|
+
return {} unless company_id && bytes
|
|
511
|
+
|
|
512
|
+
{ company_id => bytes.pack('C*') }
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def parse_service_data(data)
|
|
516
|
+
return {} unless data.is_a?(Hash)
|
|
517
|
+
|
|
518
|
+
data.transform_values { |bytes| bytes.is_a?(Array) ? bytes : [] }
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Build Service hashes from subprocess discover_services result
|
|
522
|
+
# @param raw_services [Array] Raw service data from subprocess
|
|
523
|
+
# @param device_identifier [String] Device UUID
|
|
524
|
+
# @return [Array<Hash>] Services with characteristics and paths
|
|
525
|
+
def build_services_from_result(raw_services, device_identifier)
|
|
526
|
+
(raw_services || []).map do |service_data|
|
|
527
|
+
characteristics = (service_data['characteristics'] || []).map do |char_data|
|
|
528
|
+
{
|
|
529
|
+
data: Characteristic.new(
|
|
530
|
+
uuid: char_data['uuid'],
|
|
531
|
+
flags: char_data['properties'] || [],
|
|
532
|
+
service_uuid: service_data['uuid']
|
|
533
|
+
),
|
|
534
|
+
path: "#{device_identifier}:#{service_data['uuid']}:#{char_data['uuid']}"
|
|
535
|
+
}
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
{
|
|
539
|
+
uuid: service_data['uuid'],
|
|
540
|
+
primary: service_data['primary'] != false,
|
|
541
|
+
characteristics: characteristics
|
|
542
|
+
}
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def handle_response_error(response)
|
|
547
|
+
error = response[:error]
|
|
548
|
+
message = error['message'] || error[:message]
|
|
549
|
+
platform_error = (error['data'] || error[:data])&.dig('platform_error')
|
|
550
|
+
full_message = platform_error ? "#{message} (#{platform_error})" : message
|
|
551
|
+
|
|
552
|
+
case message
|
|
553
|
+
when /not powered on/i
|
|
554
|
+
raise BluetoothOffError
|
|
555
|
+
when /unauthorized/i
|
|
556
|
+
raise PermissionError, 'access Bluetooth on macOS'
|
|
557
|
+
when /not connected/i
|
|
558
|
+
raise NotConnectedError, full_message
|
|
559
|
+
when /device not found/i, /no peripheral/i, /unknown device/i
|
|
560
|
+
raise DeviceNotFoundError.new(full_message)
|
|
561
|
+
when /connection.*(failed|refused)/i
|
|
562
|
+
raise ConnectionFailed.new(nil, full_message)
|
|
563
|
+
when /connection.*timeout/i, /timed? ?out/i
|
|
564
|
+
raise ConnectionTimeoutError
|
|
565
|
+
when /service.*discover/i, /discover.*(?:fail|error)/i, /fail.*discover/i
|
|
566
|
+
raise ServiceDiscoveryError, full_message
|
|
567
|
+
when /characteristic not found/i
|
|
568
|
+
raise CharacteristicNotFoundError
|
|
569
|
+
else
|
|
570
|
+
raise Error, full_message
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Handle notification event from subprocess
|
|
575
|
+
# @param params [Hash] Notification parameters from subprocess
|
|
576
|
+
def handle_notification(params)
|
|
577
|
+
return unless params
|
|
578
|
+
|
|
579
|
+
device_uuid = params['device_uuid']
|
|
580
|
+
service_uuid = params['service_uuid']
|
|
581
|
+
char_uuid = params['char_uuid']
|
|
582
|
+
value = params['value']
|
|
583
|
+
|
|
584
|
+
identifier = "#{device_uuid}:#{service_uuid}:#{char_uuid}"
|
|
585
|
+
callback = @state_mutex.synchronize { @subscriptions[identifier] }
|
|
586
|
+
return unless callback
|
|
587
|
+
|
|
588
|
+
# Convert byte array to binary string
|
|
589
|
+
binary_value = (value || []).map(&:to_i).pack('C*')
|
|
590
|
+
callback.call(binary_value)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# Handle disconnect event from subprocess
|
|
594
|
+
# @param params [Hash] Disconnect parameters from subprocess
|
|
595
|
+
def handle_disconnected(params)
|
|
596
|
+
return unless params
|
|
597
|
+
|
|
598
|
+
uuid = params['uuid']
|
|
599
|
+
reason_str = params['reason'] || 'unknown'
|
|
600
|
+
|
|
601
|
+
# Map string reason to symbol
|
|
602
|
+
reason = case reason_str
|
|
603
|
+
when 'user_requested' then :user_requested
|
|
604
|
+
when 'timeout' then :timeout
|
|
605
|
+
when 'remote_disconnect' then :remote_disconnect
|
|
606
|
+
when 'connection_failed' then :connection_failed
|
|
607
|
+
when 'link_loss' then :link_loss
|
|
608
|
+
else :unknown
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Clean up tracking and get connection object atomically (thread-safe)
|
|
612
|
+
connection = @state_mutex.synchronize do
|
|
613
|
+
@connected_devices.delete(uuid)
|
|
614
|
+
@device_services.delete(uuid)
|
|
615
|
+
@subscriptions.delete_if { |char_id, _| char_id.start_with?(uuid) }
|
|
616
|
+
@connection_objects.delete(uuid)
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Notify Connection object OUTSIDE mutex to prevent deadlock
|
|
620
|
+
connection&.handle_disconnect(reason)
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# Parse characteristic identifier into components
|
|
624
|
+
# @param identifier [String] Format: "device_uuid:service_uuid:char_uuid"
|
|
625
|
+
# @return [Array<String>] [device_uuid, service_uuid, char_uuid]
|
|
626
|
+
# @raise [ArgumentError] if identifier is invalid
|
|
627
|
+
def parse_char_identifier(identifier)
|
|
628
|
+
parts = identifier.split(':')
|
|
629
|
+
raise ArgumentError, "Invalid characteristic identifier: #{identifier}" unless parts.length == 3
|
|
630
|
+
|
|
631
|
+
parts
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# Translate non-RBLE errors: raise specific RBLE errors for known
|
|
635
|
+
# patterns, return message string for unknown errors (wrapped by caller).
|
|
636
|
+
# @param error [StandardError] The error to translate
|
|
637
|
+
# @return [String] Translated error message (for unknown patterns)
|
|
638
|
+
# @raise [NotConnectedError, CharacteristicNotFoundError, ConnectionTimeoutError]
|
|
639
|
+
def translate_error(error)
|
|
640
|
+
case error.message
|
|
641
|
+
when /not connected/i
|
|
642
|
+
raise NotConnectedError, 'Device not connected'
|
|
643
|
+
when /characteristic not found/i
|
|
644
|
+
raise CharacteristicNotFoundError
|
|
645
|
+
when /timeout/i
|
|
646
|
+
raise ConnectionTimeoutError
|
|
647
|
+
else
|
|
648
|
+
error.message
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
end
|