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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +169 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +514 -0
  5. data/exe/rble +14 -0
  6. data/ext/macos_ble/Package.swift +20 -0
  7. data/ext/macos_ble/Sources/RBLEHelper/BLEManager.swift +783 -0
  8. data/ext/macos_ble/Sources/RBLEHelper/Protocol.swift +173 -0
  9. data/ext/macos_ble/Sources/RBLEHelper/main.swift +645 -0
  10. data/ext/macos_ble/extconf.rb +73 -0
  11. data/lib/rble/backend/base.rb +181 -0
  12. data/lib/rble/backend/bluez.rb +1279 -0
  13. data/lib/rble/backend/corebluetooth.rb +653 -0
  14. data/lib/rble/backend.rb +193 -0
  15. data/lib/rble/bluez/adapter.rb +169 -0
  16. data/lib/rble/bluez/async_call.rb +85 -0
  17. data/lib/rble/bluez/async_connection_operations.rb +492 -0
  18. data/lib/rble/bluez/async_gatt_operations.rb +249 -0
  19. data/lib/rble/bluez/async_introspection.rb +151 -0
  20. data/lib/rble/bluez/dbus_connection.rb +64 -0
  21. data/lib/rble/bluez/dbus_session.rb +344 -0
  22. data/lib/rble/bluez/device.rb +86 -0
  23. data/lib/rble/bluez/event_loop.rb +153 -0
  24. data/lib/rble/bluez/gatt_operation_queue.rb +129 -0
  25. data/lib/rble/bluez/pairing_agent.rb +132 -0
  26. data/lib/rble/bluez/pairing_session.rb +212 -0
  27. data/lib/rble/bluez/retry_policy.rb +55 -0
  28. data/lib/rble/bluez.rb +33 -0
  29. data/lib/rble/characteristic.rb +237 -0
  30. data/lib/rble/cli/adapter.rb +88 -0
  31. data/lib/rble/cli/characteristic_helpers.rb +154 -0
  32. data/lib/rble/cli/doctor.rb +309 -0
  33. data/lib/rble/cli/formatters/json.rb +122 -0
  34. data/lib/rble/cli/formatters/text.rb +157 -0
  35. data/lib/rble/cli/hex_dump.rb +48 -0
  36. data/lib/rble/cli/monitor.rb +129 -0
  37. data/lib/rble/cli/pair.rb +103 -0
  38. data/lib/rble/cli/paired.rb +22 -0
  39. data/lib/rble/cli/read.rb +55 -0
  40. data/lib/rble/cli/scan.rb +88 -0
  41. data/lib/rble/cli/show.rb +109 -0
  42. data/lib/rble/cli/status.rb +25 -0
  43. data/lib/rble/cli/unpair.rb +39 -0
  44. data/lib/rble/cli/value_parser.rb +211 -0
  45. data/lib/rble/cli/write.rb +196 -0
  46. data/lib/rble/cli.rb +152 -0
  47. data/lib/rble/company_ids.rb +90 -0
  48. data/lib/rble/connection.rb +539 -0
  49. data/lib/rble/device.rb +54 -0
  50. data/lib/rble/errors.rb +317 -0
  51. data/lib/rble/gatt/uuid_database.rb +395 -0
  52. data/lib/rble/scanner.rb +219 -0
  53. data/lib/rble/service.rb +41 -0
  54. data/lib/rble/tasks/check.rake +154 -0
  55. data/lib/rble/tasks/integration.rake +242 -0
  56. data/lib/rble/tasks.rb +8 -0
  57. data/lib/rble/version.rb +5 -0
  58. data/lib/rble.rb +62 -0
  59. metadata +120 -0
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ module GATT
5
+ # Database of Bluetooth SIG assigned UUIDs and common vendor UUIDs
6
+ # for GATT services, characteristics, and descriptors.
7
+ #
8
+ # Loaded on-demand (not auto-required by lib/rble.rb).
9
+ # Use: require 'rble/gatt/uuid_database'
10
+ #
11
+ # @example Resolve a service UUID
12
+ # RBLE::GATT::UUIDDatabase.resolve("180d", type: :service) # => "Heart Rate"
13
+ #
14
+ # @example Resolve without specifying type
15
+ # RBLE::GATT::UUIDDatabase.resolve("2a37") # => "Heart Rate Measurement"
16
+ #
17
+ # @example Check if UUID is standard Bluetooth SIG
18
+ # RBLE::GATT::UUIDDatabase.standard_uuid?("180d") # => true
19
+ module UUIDDatabase
20
+ # Bluetooth SIG Assigned Services + common vendor services
21
+ # Standard UUIDs keyed by short 4-char hex (lowercase)
22
+ # Vendor UUIDs keyed by full 128-bit UUID (lowercase)
23
+ SERVICES = {
24
+ # Generic Access Profile
25
+ "1800" => "Generic Access",
26
+ "1801" => "Generic Attribute",
27
+
28
+ # Alert / Proximity
29
+ "1802" => "Immediate Alert",
30
+ "1803" => "Link Loss",
31
+ "1804" => "Tx Power",
32
+
33
+ # Time
34
+ "1805" => "Current Time",
35
+ "1806" => "Reference Time Update",
36
+ "1807" => "Next DST Change",
37
+
38
+ # Health
39
+ "1808" => "Glucose",
40
+ "1809" => "Health Thermometer",
41
+
42
+ # Device Information
43
+ "180a" => "Device Information",
44
+
45
+ # Heart Rate
46
+ "180d" => "Heart Rate",
47
+
48
+ # Phone
49
+ "180e" => "Phone Alert Status",
50
+
51
+ # Battery
52
+ "180f" => "Battery Service",
53
+
54
+ # Blood Pressure
55
+ "1810" => "Blood Pressure",
56
+
57
+ # Alert Notification
58
+ "1811" => "Alert Notification",
59
+
60
+ # HID
61
+ "1812" => "Human Interface Device",
62
+
63
+ # Scan Parameters
64
+ "1813" => "Scan Parameters",
65
+
66
+ # Running / Cycling
67
+ "1814" => "Running Speed and Cadence",
68
+ "1815" => "Automation IO",
69
+ "1816" => "Cycling Speed and Cadence",
70
+ "1818" => "Cycling Power",
71
+
72
+ # Location
73
+ "1819" => "Location and Navigation",
74
+
75
+ # Environmental / Body
76
+ "181a" => "Environmental Sensing",
77
+ "181b" => "Body Composition",
78
+ "181c" => "User Data",
79
+ "181d" => "Weight Scale",
80
+
81
+ # Bond Management
82
+ "181e" => "Bond Management",
83
+
84
+ # Continuous Glucose
85
+ "181f" => "Continuous Glucose Monitoring",
86
+
87
+ # Internet / Indoor
88
+ "1820" => "Internet Protocol Support",
89
+ "1821" => "Indoor Positioning",
90
+
91
+ # Pulse Oximeter
92
+ "1822" => "Pulse Oximeter",
93
+
94
+ # HTTP / Transport
95
+ "1823" => "HTTP Proxy",
96
+ "1824" => "Transport Discovery",
97
+
98
+ # Object Transfer
99
+ "1825" => "Object Transfer",
100
+
101
+ # Fitness
102
+ "1826" => "Fitness Machine",
103
+
104
+ # Mesh
105
+ "1827" => "Mesh Provisioning",
106
+ "1828" => "Mesh Proxy",
107
+
108
+ # Reconnection
109
+ "1829" => "Reconnection Configuration",
110
+
111
+ # Insulin
112
+ "183a" => "Insulin Delivery",
113
+
114
+ # Binary Sensor
115
+ "183b" => "Binary Sensor",
116
+
117
+ # Emergency
118
+ "183c" => "Emergency Configuration",
119
+
120
+ # Physical Activity
121
+ "183e" => "Physical Activity Monitor",
122
+
123
+ # Audio / Volume / Media (LE Audio)
124
+ "1843" => "Audio Input Control",
125
+ "1844" => "Volume Control",
126
+ "1845" => "Volume Offset Control",
127
+ "1846" => "Coordinated Set Identification",
128
+ "1847" => "Device Time",
129
+ "1848" => "Media Control",
130
+ "1849" => "Generic Media Control",
131
+ "184a" => "Constant Tone Extension",
132
+ "184b" => "Telephone Bearer",
133
+ "184c" => "Generic Telephone Bearer",
134
+ "184d" => "Microphone Control",
135
+ "184e" => "Audio Stream Control",
136
+ "184f" => "Broadcast Audio Scan",
137
+ "1850" => "Published Audio Capabilities",
138
+ "1851" => "Basic Audio Announcement",
139
+ "1852" => "Broadcast Audio Announcement",
140
+ "1853" => "Common Audio",
141
+ "1854" => "Hearing Access",
142
+ "1855" => "Telephony and Media Audio",
143
+ "1856" => "Public Broadcast Announcement",
144
+ "1857" => "Electronic Shelf Label",
145
+ "1858" => "Gaming Audio",
146
+ "1859" => "Mesh Proxy Solicitation",
147
+
148
+ # Vendor: Nordic UART Service
149
+ "6e400001-b5a3-f393-e0a9-e50e24dcca9e" => "Nordic UART Service",
150
+
151
+ # Vendor: Nordic DFU (Device Firmware Update)
152
+ "00001530-1212-efde-1523-785feabcd123" => "Nordic DFU",
153
+
154
+ # Vendor: Apple ANCS (Apple Notification Center Service)
155
+ "7905f431-b5ce-4e99-a40f-4b1e122d00d0" => "Apple ANCS",
156
+
157
+ # Vendor: Apple AMS (Apple Media Service)
158
+ "89d3502b-0f36-433a-8ef4-c502ad55f8dc" => "Apple AMS",
159
+
160
+ # Vendor: Google Eddystone
161
+ "feaa" => "Google Eddystone",
162
+
163
+ # Vendor: Google Fast Pair
164
+ "fe2c" => "Google Fast Pair",
165
+
166
+ # Vendor: TI OAD (Over-the-Air Download)
167
+ "f000ffc0-0451-4000-b000-000000000000" => "TI OAD",
168
+
169
+ # Vendor: Exposure Notification (COVID-19)
170
+ "fd6f" => "Exposure Notification",
171
+ }.freeze
172
+
173
+ # Bluetooth SIG Assigned Characteristics + common vendor characteristics
174
+ # Standard UUIDs keyed by short 4-char hex (lowercase)
175
+ # Vendor UUIDs keyed by full 128-bit UUID (lowercase)
176
+ CHARACTERISTICS = {
177
+ # GAP Characteristics
178
+ "2a00" => "Device Name",
179
+ "2a01" => "Appearance",
180
+ "2a02" => "Peripheral Privacy Flag",
181
+ "2a03" => "Reconnection Address",
182
+ "2a04" => "Peripheral Preferred Connection Parameters",
183
+ "2a05" => "Service Changed",
184
+
185
+ # Alert / Tx Power
186
+ "2a06" => "Alert Level",
187
+ "2a07" => "Tx Power Level",
188
+
189
+ # Date / Time
190
+ "2a08" => "Date Time",
191
+ "2a09" => "Day of Week",
192
+ "2a0a" => "Day Date Time",
193
+
194
+ # Temperature
195
+ "2a1c" => "Temperature Measurement",
196
+ "2a1d" => "Temperature Type",
197
+ "2a1e" => "Intermediate Temperature",
198
+
199
+ # Glucose
200
+ "2a18" => "Glucose Measurement",
201
+
202
+ # Battery
203
+ "2a19" => "Battery Level",
204
+
205
+ # Measurement Interval
206
+ "2a21" => "Measurement Interval",
207
+
208
+ # Boot Keyboard / Mouse
209
+ "2a22" => "Boot Keyboard Input Report",
210
+
211
+ # Device Information
212
+ "2a23" => "System ID",
213
+ "2a24" => "Model Number String",
214
+ "2a25" => "Serial Number String",
215
+ "2a26" => "Firmware Revision String",
216
+ "2a27" => "Hardware Revision String",
217
+ "2a28" => "Software Revision String",
218
+ "2a29" => "Manufacturer Name String",
219
+ "2a2a" => "IEEE Regulatory Certification Data List",
220
+
221
+ # Current Time
222
+ "2a2b" => "Current Time",
223
+
224
+ # Scan
225
+ "2a31" => "Scan Refresh",
226
+
227
+ # Boot Keyboard / Mouse (continued)
228
+ "2a32" => "Boot Keyboard Output Report",
229
+ "2a33" => "Boot Mouse Input Report",
230
+
231
+ # Blood Pressure
232
+ "2a35" => "Blood Pressure Measurement",
233
+ "2a36" => "Intermediate Cuff Pressure",
234
+
235
+ # Heart Rate
236
+ "2a37" => "Heart Rate Measurement",
237
+ "2a38" => "Body Sensor Location",
238
+ "2a39" => "Heart Rate Control Point",
239
+
240
+ # Alert Status / Ringer
241
+ "2a3f" => "Alert Status",
242
+ "2a40" => "Ringer Control Point",
243
+ "2a41" => "Ringer Setting",
244
+ "2a42" => "Alert Category ID Bit Mask",
245
+ "2a43" => "Alert Category ID",
246
+ "2a44" => "Alert Notification Control Point",
247
+ "2a45" => "Unread Alert Status",
248
+ "2a46" => "New Alert",
249
+ "2a47" => "Supported New Alert Category",
250
+ "2a48" => "Supported Unread Alert Category",
251
+
252
+ # Blood Pressure Feature
253
+ "2a49" => "Blood Pressure Feature",
254
+
255
+ # HID
256
+ "2a4a" => "HID Information",
257
+ "2a4b" => "Report Map",
258
+ "2a4c" => "HID Control Point",
259
+ "2a4d" => "Report",
260
+ "2a4e" => "Protocol Mode",
261
+
262
+ # PnP ID
263
+ "2a50" => "PnP ID",
264
+
265
+ # Glucose Feature
266
+ "2a51" => "Glucose Feature",
267
+
268
+ # Running Speed and Cadence
269
+ "2a53" => "RSC Measurement",
270
+ "2a54" => "RSC Feature",
271
+ "2a55" => "SC Control Point",
272
+
273
+ # Cycling Speed and Cadence
274
+ "2a5b" => "CSC Measurement",
275
+ "2a5c" => "CSC Feature",
276
+ "2a5d" => "Sensor Location",
277
+
278
+ # Cycling Power
279
+ "2a63" => "Cycling Power Measurement",
280
+ "2a65" => "Cycling Power Feature",
281
+ "2a66" => "Cycling Power Control Point",
282
+
283
+ # Location and Navigation
284
+ "2a67" => "Location and Speed",
285
+ "2a68" => "Navigation",
286
+ "2a6a" => "LN Feature",
287
+ "2a6b" => "LN Control Point",
288
+
289
+ # Environmental Sensing
290
+ "2a6c" => "Elevation",
291
+ "2a6d" => "Pressure",
292
+ "2a6e" => "Temperature",
293
+ "2a6f" => "Humidity",
294
+ "2a70" => "True Wind Speed",
295
+ "2a72" => "Apparent Wind Speed",
296
+ "2a75" => "Pollen Concentration",
297
+ "2a76" => "UV Index",
298
+ "2a77" => "Irradiance",
299
+ "2a78" => "Rainfall",
300
+ "2a79" => "Wind Chill",
301
+
302
+ # Fitness Machine
303
+ "2acc" => "Fitness Machine Feature",
304
+ "2acd" => "Treadmill Data",
305
+ "2ad2" => "Indoor Bike Data",
306
+ "2ad3" => "Training Status",
307
+ "2ad4" => "Supported Speed Range",
308
+ "2ad6" => "Supported Resistance Level Range",
309
+
310
+ # Vendor: Nordic UART
311
+ "6e400002-b5a3-f393-e0a9-e50e24dcca9e" => "Nordic UART RX",
312
+ "6e400003-b5a3-f393-e0a9-e50e24dcca9e" => "Nordic UART TX",
313
+
314
+ # Vendor: Nordic DFU
315
+ "00001531-1212-efde-1523-785feabcd123" => "Nordic DFU Control Point",
316
+ "00001532-1212-efde-1523-785feabcd123" => "Nordic DFU Packet",
317
+ }.freeze
318
+
319
+ # Bluetooth SIG Assigned Descriptors
320
+ # Standard UUIDs keyed by short 4-char hex (lowercase)
321
+ DESCRIPTORS = {
322
+ "2900" => "Characteristic Extended Properties",
323
+ "2901" => "Characteristic User Description",
324
+ "2902" => "Client Characteristic Configuration",
325
+ "2903" => "Server Characteristic Configuration",
326
+ "2904" => "Characteristic Presentation Format",
327
+ "2905" => "Characteristic Aggregate Format",
328
+ "2906" => "Valid Range",
329
+ "2907" => "External Report Reference",
330
+ "2908" => "Report Reference",
331
+ "2909" => "Number of Digitals",
332
+ "290a" => "Value Trigger Setting",
333
+ "290b" => "Environmental Sensing Configuration",
334
+ "290c" => "Environmental Sensing Measurement",
335
+ "290d" => "Environmental Sensing Trigger Setting",
336
+ "290e" => "Time Trigger Setting",
337
+ }.freeze
338
+
339
+ # Bluetooth Base UUID pattern for standard 16-bit UUIDs
340
+ BLUETOOTH_BASE_UUID_RE = /\A0000([0-9a-f]{4})-0000-1000-8000-00805f9b34fb\z/i
341
+
342
+ # Short UUID pattern (4-char hex)
343
+ SHORT_UUID_RE = /\A[0-9a-f]{4}\z/i
344
+
345
+ # Mapping from type symbols to hash constants
346
+ TYPE_MAP = {
347
+ service: SERVICES,
348
+ characteristic: CHARACTERISTICS,
349
+ descriptor: DESCRIPTORS,
350
+ }.freeze
351
+
352
+ # Resolve a UUID to a human-readable name.
353
+ #
354
+ # @param uuid [String] UUID to resolve (short 4-char hex or full 128-bit)
355
+ # @param type [Symbol, nil] :service, :characteristic, :descriptor, or nil for all
356
+ # @return [String, nil] Human-readable name, or nil if unknown
357
+ def self.resolve(uuid, type: nil)
358
+ short = extract_short_uuid(uuid)
359
+
360
+ if type
361
+ db = TYPE_MAP[type]
362
+ return nil unless db
363
+
364
+ db[short]
365
+ else
366
+ # Search all databases in order: services, characteristics, descriptors
367
+ SERVICES[short] || CHARACTERISTICS[short] || DESCRIPTORS[short]
368
+ end
369
+ end
370
+
371
+ # Extract the short 4-char UUID from a standard Bluetooth Base UUID.
372
+ # Returns the full UUID unchanged if it is not in the Bluetooth Base range.
373
+ #
374
+ # @param uuid [String] UUID to extract from
375
+ # @return [String] Short UUID (lowercase) or full UUID (lowercase)
376
+ def self.extract_short_uuid(uuid)
377
+ downcased = uuid.downcase
378
+ if (match = BLUETOOTH_BASE_UUID_RE.match(downcased))
379
+ match[1]
380
+ else
381
+ downcased
382
+ end
383
+ end
384
+
385
+ # Check if a UUID is in the standard Bluetooth SIG range.
386
+ #
387
+ # @param uuid [String] UUID to check
388
+ # @return [Boolean] true if standard (short 4-char hex or Bluetooth Base UUID)
389
+ def self.standard_uuid?(uuid)
390
+ downcased = uuid.downcase
391
+ SHORT_UUID_RE.match?(downcased) || BLUETOOTH_BASE_UUID_RE.match?(downcased)
392
+ end
393
+ end
394
+ end
395
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ # BLE device scanner
5
+ #
6
+ # Provides a high-level API for scanning BLE devices with options for
7
+ # filtering, timeout, and continuous monitoring.
8
+ #
9
+ # @example Basic scanning (blocking with timeout)
10
+ # RBLE.scan(timeout: 10) do |device|
11
+ # puts "Found: #{device.name} (#{device.address})"
12
+ # end
13
+ #
14
+ # @example Manual stop control
15
+ # scanner = RBLE.scan do |device|
16
+ # puts device.name
17
+ # scanner.stop if device.name == "MyDevice"
18
+ # end
19
+ #
20
+ # @example Filter by service UUID
21
+ # RBLE.scan(service_uuids: ['180d']) do |device|
22
+ # puts "Heart rate monitor: #{device.name}"
23
+ # end
24
+ #
25
+ # @example Continuous RSSI monitoring (RuuviTag style)
26
+ # RBLE.scan(allow_duplicates: true, timeout: 60) do |device|
27
+ # puts "#{device.address}: RSSI #{device.rssi}"
28
+ # end
29
+ #
30
+ class Scanner
31
+ attr_reader :backend
32
+
33
+ # Create a new scanner
34
+ #
35
+ # @param service_uuids [Array<String>, nil] Filter by service UUIDs
36
+ # @param timeout [Numeric, nil] Stop after N seconds (nil = manual stop only)
37
+ # @param allow_duplicates [Boolean, nil] Callback on every advertisement (nil = auto based on active:)
38
+ # @param adapter [String, nil] Bluetooth adapter name (e.g., "hci0")
39
+ # @param active [Boolean] Use active scanning (true) or passive scanning (false)
40
+ # @param on_stop [Proc, nil] Callback when scan stops
41
+ def initialize(service_uuids: nil, timeout: nil, allow_duplicates: nil, adapter: nil, active: true, on_stop: nil)
42
+ @service_uuids = service_uuids
43
+ @timeout = timeout
44
+ @allow_duplicates = allow_duplicates.nil? ? !active : allow_duplicates
45
+ @active = active
46
+ @adapter = adapter
47
+ @on_stop = on_stop
48
+ @backend = nil
49
+ @stop_requested = false
50
+ @wake_queue = nil
51
+ @started = false
52
+ end
53
+
54
+ # Start scanning with a callback block
55
+ #
56
+ # @yield [Device] Called when device is discovered/updated
57
+ # @return [self] Returns self for stop control
58
+ # @raise [ScanInProgressError] if this scanner is already running
59
+ # @raise [AdapterNotFoundError] if no Bluetooth adapter available
60
+ # @raise [AdapterDisabledError] if adapter is not powered on
61
+ def start(&block)
62
+ raise ScanInProgressError if @started
63
+ raise ArgumentError, "Block required" unless block_given?
64
+
65
+ @started = true
66
+ @stop_requested = false
67
+ @backend = Backend.for_platform
68
+
69
+ begin
70
+ @backend.start_scan(
71
+ service_uuids: @service_uuids,
72
+ allow_duplicates: @allow_duplicates,
73
+ adapter: @adapter,
74
+ active: @active,
75
+ &block
76
+ )
77
+
78
+ # Capture direct queue reference for signal-safe stop
79
+ # Must be done here (not in trap) to avoid mutex access from signal context
80
+ @wake_queue = @backend.respond_to?(:scan_event_queue) ? @backend.scan_event_queue : nil
81
+
82
+ # Process events until stop or timeout
83
+ process_until_stop
84
+
85
+ ensure
86
+ # Ensure cleanup on any error or normal completion
87
+ @wake_queue = nil
88
+ cleanup_scan
89
+ @on_stop&.call
90
+ end
91
+
92
+ self
93
+ end
94
+
95
+ # Stop the current scan
96
+ #
97
+ # Signal-safe: can be called from trap context.
98
+ # Sets the stop flag AND wakes the event loop queue so process_events
99
+ # returns immediately instead of waiting up to 500ms.
100
+ # Thread::Queue#push is signal-safe (can be called from trap context).
101
+ #
102
+ # @return [void]
103
+ def stop
104
+ @stop_requested = true
105
+ # Wake the event loop queue so process_events returns immediately
106
+ @wake_queue&.push(RBLE::BlueZ::Event.new(type: :shutdown)) if defined?(RBLE::BlueZ::Event)
107
+ end
108
+
109
+ # Check if scan is running
110
+ #
111
+ # @return [Boolean]
112
+ def scanning?
113
+ @started && @backend&.scanning?
114
+ end
115
+
116
+ private
117
+
118
+ def process_until_stop
119
+ deadline = @timeout ? Time.now + @timeout : nil
120
+
121
+ loop do
122
+ break if @stop_requested
123
+
124
+ # Calculate remaining time
125
+ remaining = if deadline
126
+ time_left = deadline - Time.now
127
+ break if time_left <= 0
128
+ [time_left, 0.5].min # Process in chunks for responsiveness
129
+ else
130
+ 0.5 # Default poll interval
131
+ end
132
+
133
+ @backend.process_events(timeout: remaining)
134
+ end
135
+ end
136
+
137
+ def cleanup_scan
138
+ @backend&.stop_scan
139
+ @backend = nil
140
+ @started = false
141
+ end
142
+ end
143
+
144
+ class << self
145
+ # Scan for BLE devices
146
+ #
147
+ # @param service_uuids [Array<String>, nil] Filter by service UUIDs
148
+ # @param timeout [Numeric, nil] Stop after N seconds
149
+ # @param allow_duplicates [Boolean, nil] Callback on every advertisement (nil = auto based on active:)
150
+ # @param adapter [String, nil] Bluetooth adapter name
151
+ # @param active [Boolean] Use active scanning (true) or passive scanning (false)
152
+ # @param on_stop [Proc, nil] Callback when scan stops
153
+ # @yield [Device] Called when device discovered
154
+ # @return [Scanner] Scanner instance for stop control
155
+ #
156
+ # @example
157
+ # RBLE.scan(timeout: 5) { |d| puts d.name }
158
+ #
159
+ def scan(service_uuids: nil, timeout: nil, allow_duplicates: nil, adapter: nil, active: true, on_stop: nil, &block)
160
+ scanner = Scanner.new(
161
+ service_uuids: service_uuids,
162
+ timeout: timeout,
163
+ allow_duplicates: allow_duplicates,
164
+ adapter: adapter,
165
+ active: active,
166
+ on_stop: on_stop
167
+ )
168
+ scanner.start(&block)
169
+ scanner
170
+ end
171
+
172
+ # List available Bluetooth adapters
173
+ #
174
+ # @return [Array<Hash>] Array of adapter info hashes
175
+ # @example
176
+ # RBLE.adapters
177
+ # # => [{name: "hci0", address: "AA:BB:CC:DD:EE:FF", powered: true}]
178
+ #
179
+ def adapters
180
+ Backend.for_platform.adapters
181
+ end
182
+
183
+ # Find a specific device by address
184
+ #
185
+ # Scans until the device with the given address is found, or timeout expires.
186
+ # Stops scanning immediately when the device is found.
187
+ #
188
+ # @param address [String] Device address (MAC on Linux, UUID on macOS)
189
+ # @param timeout [Numeric] Maximum time to scan (default: 10 seconds)
190
+ # @param adapter [String, nil] Bluetooth adapter name
191
+ # @return [Device, nil] The device if found, nil if not found within timeout
192
+ #
193
+ # @example Find a device by address
194
+ # device = RBLE.find_device("AA:BB:CC:DD:EE:FF", timeout: 5)
195
+ # if device
196
+ # puts "Found #{device.name}"
197
+ # end
198
+ #
199
+ # @example Find and connect
200
+ # if device = RBLE.find_device("AA:BB:CC:DD:EE:FF")
201
+ # conn = RBLE.connect(device.address)
202
+ # end
203
+ #
204
+ def find_device(address, timeout: 10, adapter: nil)
205
+ found_device = nil
206
+ normalized_address = address.upcase
207
+
208
+ scanner = Scanner.new(timeout: timeout, adapter: adapter)
209
+ scanner.start do |device|
210
+ if device.address.upcase == normalized_address
211
+ found_device = device
212
+ scanner.stop
213
+ end
214
+ end
215
+
216
+ found_device
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ # Immutable representation of a GATT service
5
+ #
6
+ # @!attribute uuid [String] Service UUID (128-bit format: "0000180d-0000-1000-8000-00805f9b34fb")
7
+ # @!attribute primary [Boolean] True if primary service, false if secondary
8
+ # @!attribute characteristics [Array<Characteristic, ActiveCharacteristic>] Characteristics in this service
9
+ Service = Data.define(:uuid, :primary, :characteristics) do
10
+ def initialize(uuid:, primary: true, characteristics: [])
11
+ super
12
+ end
13
+
14
+ # Get short UUID for standard services (e.g., "180d" from full UUID)
15
+ def short_uuid
16
+ if uuid =~ /^0000([0-9a-f]{4})-0000-1000-8000-00805f9b34fb$/i
17
+ Regexp.last_match(1).downcase
18
+ else
19
+ uuid
20
+ end
21
+ end
22
+
23
+ # Find a characteristic by UUID (supports short UUID like "2a37")
24
+ # @param char_uuid [String] UUID to find
25
+ # @return [Characteristic, ActiveCharacteristic, nil]
26
+ def characteristic(char_uuid)
27
+ normalized = normalize_uuid(char_uuid)
28
+ characteristics.find { |c| c.uuid.downcase == normalized || c.short_uuid == char_uuid.downcase }
29
+ end
30
+
31
+ private
32
+
33
+ def normalize_uuid(short_uuid)
34
+ if short_uuid.length == 4
35
+ "0000#{short_uuid.downcase}-0000-1000-8000-00805f9b34fb"
36
+ else
37
+ short_uuid.downcase
38
+ end
39
+ end
40
+ end
41
+ end