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,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
|
data/lib/rble/scanner.rb
ADDED
|
@@ -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
|
data/lib/rble/service.rb
ADDED
|
@@ -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
|