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,783 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CoreBluetooth
|
|
3
|
+
|
|
4
|
+
// MARK: - BLEManager
|
|
5
|
+
|
|
6
|
+
/// CoreBluetooth wrapper that manages Bluetooth scanning and device discovery.
|
|
7
|
+
/// This class handles all interaction with CoreBluetooth and emits events via the onEvent callback.
|
|
8
|
+
class BLEManager: NSObject, CBCentralManagerDelegate {
|
|
9
|
+
private var centralManager: CBCentralManager!
|
|
10
|
+
private var isScanning = false
|
|
11
|
+
private var allowDuplicates = false
|
|
12
|
+
private var serviceUUIDs: [CBUUID]?
|
|
13
|
+
private var reportedPeripherals: Set<String> = [] // Track already-reported UUIDs when !allowDuplicates
|
|
14
|
+
|
|
15
|
+
// Peripheral tracking
|
|
16
|
+
private var discoveredPeripherals: [String: CBPeripheral] = [:] // UUID -> peripheral
|
|
17
|
+
private var connectedPeripherals: [String: CBPeripheral] = [:]
|
|
18
|
+
private var pendingConnections: [String: (Result<Void, Error>) -> Void] = [:] // UUID -> completion
|
|
19
|
+
private var pendingDisconnects: [String: () -> Void] = [:]
|
|
20
|
+
|
|
21
|
+
// Service discovery tracking
|
|
22
|
+
private var pendingServiceDiscovery: [String: (Result<Void, Error>) -> Void] = [:]
|
|
23
|
+
private var pendingCharacteristicDiscovery: [String: Int] = [:] // UUID -> remaining services
|
|
24
|
+
|
|
25
|
+
// GATT operation tracking
|
|
26
|
+
private var pendingReads: [String: (Result<Data, Error>) -> Void] = [:] // deviceUUID:charUUID -> completion
|
|
27
|
+
private var pendingWrites: [String: (Result<Void, Error>) -> Void] = [:]
|
|
28
|
+
private var subscriptions: Set<String> = [] // deviceUUID:charUUID
|
|
29
|
+
|
|
30
|
+
/// Callback to send events to stdout
|
|
31
|
+
var onEvent: ((Event) -> Void)?
|
|
32
|
+
|
|
33
|
+
override init() {
|
|
34
|
+
super.init()
|
|
35
|
+
// Create manager on main queue for delegate callbacks
|
|
36
|
+
// nil queue = main queue, which is required for proper delegate callback handling
|
|
37
|
+
centralManager = CBCentralManager(delegate: self, queue: nil)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// MARK: - Public Methods
|
|
41
|
+
|
|
42
|
+
/// Start scanning for BLE peripherals
|
|
43
|
+
/// - Parameters:
|
|
44
|
+
/// - serviceUUIDs: Optional array of service UUID strings to filter by
|
|
45
|
+
/// - allowDuplicates: If true, receive repeated advertisements from the same device
|
|
46
|
+
/// - Throws: BLEError.notPoweredOn if Bluetooth is not enabled
|
|
47
|
+
func startScan(serviceUUIDs: [String]?, allowDuplicates: Bool) throws {
|
|
48
|
+
guard centralManager.state == .poweredOn else {
|
|
49
|
+
throw BLEError.notPoweredOn
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
self.allowDuplicates = allowDuplicates
|
|
53
|
+
self.serviceUUIDs = serviceUUIDs?.map { CBUUID(string: $0) }
|
|
54
|
+
self.reportedPeripherals = [] // Reset for new scan
|
|
55
|
+
|
|
56
|
+
let options: [String: Any] = [
|
|
57
|
+
CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
centralManager.scanForPeripherals(
|
|
61
|
+
withServices: self.serviceUUIDs,
|
|
62
|
+
options: options
|
|
63
|
+
)
|
|
64
|
+
isScanning = true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Stop scanning for BLE peripherals
|
|
68
|
+
func stopScan() {
|
|
69
|
+
centralManager.stopScan()
|
|
70
|
+
isScanning = false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Get current Bluetooth adapter state as a string
|
|
74
|
+
/// - Returns: String representation of the Bluetooth state
|
|
75
|
+
func getState() -> String {
|
|
76
|
+
switch centralManager.state {
|
|
77
|
+
case .unknown: return "unknown"
|
|
78
|
+
case .resetting: return "resetting"
|
|
79
|
+
case .unsupported: return "unsupported"
|
|
80
|
+
case .unauthorized: return "unauthorized"
|
|
81
|
+
case .poweredOff: return "powered_off"
|
|
82
|
+
case .poweredOn: return "powered_on"
|
|
83
|
+
@unknown default: return "unknown"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Check if Bluetooth is currently powered on
|
|
88
|
+
var isPoweredOn: Bool {
|
|
89
|
+
return centralManager.state == .poweredOn
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Callback for state changes (used for waitForPoweredOn)
|
|
93
|
+
private var stateChangeCallback: ((CBManagerState) -> Void)?
|
|
94
|
+
|
|
95
|
+
/// Wait for Bluetooth to be powered on
|
|
96
|
+
/// - Parameters:
|
|
97
|
+
/// - timeout: Maximum time to wait in seconds
|
|
98
|
+
/// - completion: Called with true if powered on, false if timeout or not available
|
|
99
|
+
func waitForPoweredOn(timeout: Int = 5, completion: @escaping (Bool) -> Void) {
|
|
100
|
+
// Already powered on?
|
|
101
|
+
if centralManager.state == .poweredOn {
|
|
102
|
+
completion(true)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Already in a terminal non-powered state?
|
|
107
|
+
if centralManager.state == .unsupported ||
|
|
108
|
+
centralManager.state == .unauthorized ||
|
|
109
|
+
centralManager.state == .poweredOff {
|
|
110
|
+
completion(false)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Wait for state change
|
|
115
|
+
var completed = false
|
|
116
|
+
let timeoutWorkItem = DispatchWorkItem {
|
|
117
|
+
if !completed {
|
|
118
|
+
completed = true
|
|
119
|
+
self.stateChangeCallback = nil
|
|
120
|
+
completion(false)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: timeoutWorkItem)
|
|
124
|
+
|
|
125
|
+
stateChangeCallback = { newState in
|
|
126
|
+
if !completed {
|
|
127
|
+
if newState == .poweredOn {
|
|
128
|
+
completed = true
|
|
129
|
+
timeoutWorkItem.cancel()
|
|
130
|
+
self.stateChangeCallback = nil
|
|
131
|
+
completion(true)
|
|
132
|
+
} else if newState == .unsupported || newState == .unauthorized || newState == .poweredOff {
|
|
133
|
+
completed = true
|
|
134
|
+
timeoutWorkItem.cancel()
|
|
135
|
+
self.stateChangeCallback = nil
|
|
136
|
+
completion(false)
|
|
137
|
+
}
|
|
138
|
+
// For .unknown or .resetting, keep waiting
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// Wait for Bluetooth state to be determined (not unknown or resetting)
|
|
144
|
+
/// - Parameters:
|
|
145
|
+
/// - timeout: Maximum time to wait in seconds
|
|
146
|
+
/// - completion: Called with true if state is now known, false if timeout
|
|
147
|
+
func waitForStateKnown(timeout: Int = 2, completion: @escaping (Bool) -> Void) {
|
|
148
|
+
// Already in a known state?
|
|
149
|
+
let state = centralManager.state
|
|
150
|
+
if state != .unknown && state != .resetting {
|
|
151
|
+
completion(true)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Wait for state change
|
|
156
|
+
var completed = false
|
|
157
|
+
let timeoutWorkItem = DispatchWorkItem {
|
|
158
|
+
if !completed {
|
|
159
|
+
completed = true
|
|
160
|
+
self.stateChangeCallback = nil
|
|
161
|
+
completion(false)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: timeoutWorkItem)
|
|
165
|
+
|
|
166
|
+
stateChangeCallback = { newState in
|
|
167
|
+
if !completed && newState != .unknown && newState != .resetting {
|
|
168
|
+
completed = true
|
|
169
|
+
timeoutWorkItem.cancel()
|
|
170
|
+
self.stateChangeCallback = nil
|
|
171
|
+
completion(true)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// MARK: - CBCentralManagerDelegate
|
|
177
|
+
|
|
178
|
+
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
179
|
+
// Notify any waiters
|
|
180
|
+
stateChangeCallback?(central.state)
|
|
181
|
+
|
|
182
|
+
let event = Event(
|
|
183
|
+
method: "state_changed",
|
|
184
|
+
params: ["state": AnyCodable(getState())]
|
|
185
|
+
)
|
|
186
|
+
onEvent?(event)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func centralManager(
|
|
190
|
+
_ central: CBCentralManager,
|
|
191
|
+
didDiscover peripheral: CBPeripheral,
|
|
192
|
+
advertisementData: [String: Any],
|
|
193
|
+
rssi RSSI: NSNumber
|
|
194
|
+
) {
|
|
195
|
+
let uuid = peripheral.identifier.uuidString.uppercased()
|
|
196
|
+
|
|
197
|
+
// Store discovered peripheral for later connection
|
|
198
|
+
discoveredPeripherals[uuid] = peripheral
|
|
199
|
+
|
|
200
|
+
// When allowDuplicates is false, only report each peripheral once
|
|
201
|
+
if !allowDuplicates {
|
|
202
|
+
if reportedPeripherals.contains(uuid) {
|
|
203
|
+
return // Already reported this peripheral
|
|
204
|
+
}
|
|
205
|
+
reportedPeripherals.insert(uuid)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Build device info dictionary
|
|
209
|
+
var params: [String: AnyCodable] = [
|
|
210
|
+
"uuid": AnyCodable(peripheral.identifier.uuidString),
|
|
211
|
+
"rssi": AnyCodable(RSSI.intValue)
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
// Add device name if available (from peripheral or advertisement data)
|
|
215
|
+
if let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String {
|
|
216
|
+
params["name"] = AnyCodable(name)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Parse advertised service UUIDs
|
|
220
|
+
if let advertServiceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] {
|
|
221
|
+
params["service_uuids"] = AnyCodable(advertServiceUUIDs.map { $0.uuidString })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Parse manufacturer data
|
|
225
|
+
// First 2 bytes are company ID (little-endian), rest is payload
|
|
226
|
+
if let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data {
|
|
227
|
+
if mfgData.count >= 2 {
|
|
228
|
+
let companyId = Int(UInt16(mfgData[0]) | (UInt16(mfgData[1]) << 8))
|
|
229
|
+
let dataBytes = Array(mfgData.dropFirst(2)).map { Int($0) }
|
|
230
|
+
params["manufacturer_data"] = AnyCodable([
|
|
231
|
+
"company_id": companyId,
|
|
232
|
+
"data": dataBytes
|
|
233
|
+
])
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Parse service data (map of service UUID -> data bytes)
|
|
238
|
+
if let serviceData = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] {
|
|
239
|
+
var sdDict: [String: [Int]] = [:]
|
|
240
|
+
for (uuid, data) in serviceData {
|
|
241
|
+
sdDict[uuid.uuidString] = Array(data).map { Int($0) }
|
|
242
|
+
}
|
|
243
|
+
params["service_data"] = AnyCodable(sdDict)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Tx power level if advertised
|
|
247
|
+
if let txPower = advertisementData[CBAdvertisementDataTxPowerLevelKey] as? NSNumber {
|
|
248
|
+
params["tx_power"] = AnyCodable(txPower.intValue)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Connectable status if available
|
|
252
|
+
if let connectable = advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber {
|
|
253
|
+
params["connectable"] = AnyCodable(connectable.boolValue)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let event = Event(method: "device_discovered", params: params)
|
|
257
|
+
onEvent?(event)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// MARK: - CBCentralManagerDelegate - Connection
|
|
261
|
+
|
|
262
|
+
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
263
|
+
let uuid = peripheral.identifier.uuidString.uppercased()
|
|
264
|
+
connectedPeripherals[uuid] = peripheral
|
|
265
|
+
peripheral.delegate = self
|
|
266
|
+
|
|
267
|
+
// Notify pending connection
|
|
268
|
+
if let completion = pendingConnections.removeValue(forKey: uuid) {
|
|
269
|
+
completion(.success(()))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Emit event
|
|
273
|
+
let event = Event(method: "connected", params: ["uuid": AnyCodable(uuid)])
|
|
274
|
+
onEvent?(event)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
|
278
|
+
let uuid = peripheral.identifier.uuidString.uppercased()
|
|
279
|
+
if let completion = pendingConnections.removeValue(forKey: uuid) {
|
|
280
|
+
completion(.failure(error ?? BLEError.connectionFailed))
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
|
285
|
+
let uuid = peripheral.identifier.uuidString.uppercased()
|
|
286
|
+
connectedPeripherals.removeValue(forKey: uuid)
|
|
287
|
+
|
|
288
|
+
// Map error to reason
|
|
289
|
+
let reason = mapDisconnectReason(error)
|
|
290
|
+
|
|
291
|
+
if let completion = pendingDisconnects.removeValue(forKey: uuid) {
|
|
292
|
+
completion()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let event = Event(method: "disconnected", params: [
|
|
296
|
+
"uuid": AnyCodable(uuid),
|
|
297
|
+
"reason": AnyCodable(reason),
|
|
298
|
+
"error": AnyCodable(error?.localizedDescription as Any)
|
|
299
|
+
])
|
|
300
|
+
onEvent?(event)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// MARK: - Disconnect Reason Mapping
|
|
304
|
+
|
|
305
|
+
/// Map CoreBluetooth error to disconnect reason string
|
|
306
|
+
/// - Parameter error: The error from didDisconnectPeripheral
|
|
307
|
+
/// - Returns: String reason for disconnect event
|
|
308
|
+
private func mapDisconnectReason(_ error: Error?) -> String {
|
|
309
|
+
guard let error = error else {
|
|
310
|
+
return "user_requested"
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if let cbError = error as? CBError {
|
|
314
|
+
switch cbError.code {
|
|
315
|
+
case .connectionTimeout:
|
|
316
|
+
return "timeout"
|
|
317
|
+
case .peripheralDisconnected:
|
|
318
|
+
return "remote_disconnect"
|
|
319
|
+
case .connectionFailed:
|
|
320
|
+
return "connection_failed"
|
|
321
|
+
default:
|
|
322
|
+
return "unknown"
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Check for NSError with CoreBluetooth domain
|
|
327
|
+
let nsError = error as NSError
|
|
328
|
+
if nsError.domain == CBErrorDomain {
|
|
329
|
+
switch nsError.code {
|
|
330
|
+
case 6: // CBErrorConnectionTimeout
|
|
331
|
+
return "timeout"
|
|
332
|
+
case 7: // CBErrorPeripheralDisconnected
|
|
333
|
+
return "remote_disconnect"
|
|
334
|
+
default:
|
|
335
|
+
return "unknown"
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Generic error - likely link loss
|
|
340
|
+
return "link_loss"
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// MARK: - Connection Methods
|
|
344
|
+
|
|
345
|
+
/// Connect to a peripheral by UUID
|
|
346
|
+
/// - Parameters:
|
|
347
|
+
/// - uuid: The peripheral's UUID string
|
|
348
|
+
/// - completion: Callback with success/failure result
|
|
349
|
+
func connect(uuid: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
350
|
+
// Normalize UUID to uppercase for consistent lookup
|
|
351
|
+
let normalizedUUID = uuid.uppercased()
|
|
352
|
+
|
|
353
|
+
guard let peripheral = discoveredPeripherals[normalizedUUID] else {
|
|
354
|
+
completion(.failure(BLEError.deviceNotFound))
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
guard centralManager.state == .poweredOn else {
|
|
359
|
+
completion(.failure(BLEError.notPoweredOn))
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
pendingConnections[normalizedUUID] = completion
|
|
364
|
+
centralManager.connect(peripheral, options: nil)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/// Disconnect from a peripheral
|
|
368
|
+
/// - Parameters:
|
|
369
|
+
/// - uuid: The peripheral's UUID string
|
|
370
|
+
/// - completion: Callback when disconnection completes
|
|
371
|
+
func disconnect(uuid: String, completion: @escaping () -> Void) {
|
|
372
|
+
let normalizedUUID = uuid.uppercased()
|
|
373
|
+
guard let peripheral = connectedPeripherals[normalizedUUID] else {
|
|
374
|
+
completion()
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
pendingDisconnects[normalizedUUID] = completion
|
|
379
|
+
centralManager.cancelPeripheralConnection(peripheral)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/// Check if a peripheral is connected
|
|
383
|
+
/// - Parameter uuid: The peripheral's UUID string
|
|
384
|
+
/// - Returns: True if connected
|
|
385
|
+
func isConnected(uuid: String) -> Bool {
|
|
386
|
+
return connectedPeripherals[uuid.uppercased()] != nil
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// MARK: - GATT Operations
|
|
390
|
+
|
|
391
|
+
/// Normalize a UUID, converting 4-character short UUIDs to full 128-bit format
|
|
392
|
+
/// - Parameter uuid: The UUID string (either 4-char short or full 128-bit)
|
|
393
|
+
/// - Returns: The full 128-bit UUID string
|
|
394
|
+
private func normalizeUUID(_ uuid: String) -> String {
|
|
395
|
+
if uuid.count == 4 {
|
|
396
|
+
return "0000\(uuid.uppercased())-0000-1000-8000-00805F9B34FB"
|
|
397
|
+
}
|
|
398
|
+
return uuid.uppercased()
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/// Find a characteristic by device, service, and characteristic UUIDs
|
|
402
|
+
/// - Parameters:
|
|
403
|
+
/// - deviceUUID: The peripheral's UUID string
|
|
404
|
+
/// - serviceUUID: The service UUID (short or full)
|
|
405
|
+
/// - charUUID: The characteristic UUID (short or full)
|
|
406
|
+
/// - Returns: The CBCharacteristic if found, nil otherwise
|
|
407
|
+
func findCharacteristic(deviceUUID: String, serviceUUID: String, charUUID: String) -> CBCharacteristic? {
|
|
408
|
+
let normalizedDeviceUUID = deviceUUID.uppercased()
|
|
409
|
+
guard let peripheral = connectedPeripherals[normalizedDeviceUUID],
|
|
410
|
+
let services = peripheral.services else {
|
|
411
|
+
return nil
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Find service (support both full and short UUID)
|
|
415
|
+
// Normalize both input UUID and CoreBluetooth UUID to full form for comparison
|
|
416
|
+
let normalizedServiceUUID = normalizeUUID(serviceUUID)
|
|
417
|
+
guard let service = services.first(where: {
|
|
418
|
+
normalizeUUID($0.uuid.uuidString) == normalizedServiceUUID
|
|
419
|
+
}) else {
|
|
420
|
+
return nil
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Find characteristic
|
|
424
|
+
// Normalize both input UUID and CoreBluetooth UUID to full form for comparison
|
|
425
|
+
let normalizedCharUUID = normalizeUUID(charUUID)
|
|
426
|
+
return service.characteristics?.first(where: {
|
|
427
|
+
normalizeUUID($0.uuid.uuidString) == normalizedCharUUID
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/// Read a characteristic value
|
|
432
|
+
/// - Parameters:
|
|
433
|
+
/// - deviceUUID: The peripheral's UUID string
|
|
434
|
+
/// - serviceUUID: The service UUID (short or full)
|
|
435
|
+
/// - charUUID: The characteristic UUID (short or full)
|
|
436
|
+
/// - completion: Callback with the read data or error
|
|
437
|
+
func readCharacteristic(
|
|
438
|
+
deviceUUID: String,
|
|
439
|
+
serviceUUID: String,
|
|
440
|
+
charUUID: String,
|
|
441
|
+
completion: @escaping (Result<Data, Error>) -> Void
|
|
442
|
+
) {
|
|
443
|
+
let normalizedDeviceUUID = deviceUUID.uppercased()
|
|
444
|
+
guard let peripheral = connectedPeripherals[normalizedDeviceUUID] else {
|
|
445
|
+
completion(.failure(BLEError.notConnected))
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
guard let characteristic = findCharacteristic(
|
|
450
|
+
deviceUUID: deviceUUID,
|
|
451
|
+
serviceUUID: serviceUUID,
|
|
452
|
+
charUUID: charUUID
|
|
453
|
+
) else {
|
|
454
|
+
completion(.failure(BLEError.characteristicNotFound))
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
let key = "\(normalizedDeviceUUID):\(characteristic.uuid.uuidString.uppercased())"
|
|
459
|
+
pendingReads[key] = completion
|
|
460
|
+
peripheral.readValue(for: characteristic)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/// Write a value to a characteristic
|
|
464
|
+
/// - Parameters:
|
|
465
|
+
/// - deviceUUID: The peripheral's UUID string
|
|
466
|
+
/// - serviceUUID: The service UUID (short or full)
|
|
467
|
+
/// - charUUID: The characteristic UUID (short or full)
|
|
468
|
+
/// - data: The data to write
|
|
469
|
+
/// - withResponse: Whether to request a write response from the peripheral
|
|
470
|
+
/// - completion: Callback with success or error
|
|
471
|
+
func writeCharacteristic(
|
|
472
|
+
deviceUUID: String,
|
|
473
|
+
serviceUUID: String,
|
|
474
|
+
charUUID: String,
|
|
475
|
+
data: Data,
|
|
476
|
+
withResponse: Bool,
|
|
477
|
+
completion: @escaping (Result<Void, Error>) -> Void
|
|
478
|
+
) {
|
|
479
|
+
let normalizedDeviceUUID = deviceUUID.uppercased()
|
|
480
|
+
guard let peripheral = connectedPeripherals[normalizedDeviceUUID] else {
|
|
481
|
+
completion(.failure(BLEError.notConnected))
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
guard let characteristic = findCharacteristic(
|
|
486
|
+
deviceUUID: deviceUUID,
|
|
487
|
+
serviceUUID: serviceUUID,
|
|
488
|
+
charUUID: charUUID
|
|
489
|
+
) else {
|
|
490
|
+
completion(.failure(BLEError.characteristicNotFound))
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let writeType: CBCharacteristicWriteType = withResponse ? .withResponse : .withoutResponse
|
|
495
|
+
|
|
496
|
+
if withResponse {
|
|
497
|
+
let key = "\(normalizedDeviceUUID):\(characteristic.uuid.uuidString.uppercased())"
|
|
498
|
+
pendingWrites[key] = completion
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
peripheral.writeValue(data, for: characteristic, type: writeType)
|
|
502
|
+
|
|
503
|
+
if !withResponse {
|
|
504
|
+
// No response expected, complete immediately
|
|
505
|
+
completion(.success(()))
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/// Subscribe to notifications for a characteristic
|
|
510
|
+
/// - Parameters:
|
|
511
|
+
/// - deviceUUID: The peripheral's UUID string
|
|
512
|
+
/// - serviceUUID: The service UUID (short or full)
|
|
513
|
+
/// - charUUID: The characteristic UUID (short or full)
|
|
514
|
+
/// - completion: Callback when subscription is confirmed or fails
|
|
515
|
+
func subscribe(
|
|
516
|
+
deviceUUID: String,
|
|
517
|
+
serviceUUID: String,
|
|
518
|
+
charUUID: String,
|
|
519
|
+
completion: @escaping (Result<Void, Error>) -> Void
|
|
520
|
+
) {
|
|
521
|
+
let normalizedDeviceUUID = deviceUUID.uppercased()
|
|
522
|
+
guard let peripheral = connectedPeripherals[normalizedDeviceUUID] else {
|
|
523
|
+
completion(.failure(BLEError.notConnected))
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
guard let characteristic = findCharacteristic(
|
|
528
|
+
deviceUUID: deviceUUID,
|
|
529
|
+
serviceUUID: serviceUUID,
|
|
530
|
+
charUUID: charUUID
|
|
531
|
+
) else {
|
|
532
|
+
completion(.failure(BLEError.characteristicNotFound))
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
let key = "\(normalizedDeviceUUID):\(characteristic.uuid.uuidString.uppercased())"
|
|
537
|
+
subscriptions.insert(key)
|
|
538
|
+
|
|
539
|
+
// setNotifyValue completion comes via didUpdateNotificationState
|
|
540
|
+
pendingWrites[key] = completion
|
|
541
|
+
peripheral.setNotifyValue(true, for: characteristic)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/// Unsubscribe from notifications for a characteristic
|
|
545
|
+
/// - Parameters:
|
|
546
|
+
/// - deviceUUID: The peripheral's UUID string
|
|
547
|
+
/// - serviceUUID: The service UUID (short or full)
|
|
548
|
+
/// - charUUID: The characteristic UUID (short or full)
|
|
549
|
+
func unsubscribe(
|
|
550
|
+
deviceUUID: String,
|
|
551
|
+
serviceUUID: String,
|
|
552
|
+
charUUID: String
|
|
553
|
+
) {
|
|
554
|
+
let normalizedDeviceUUID = deviceUUID.uppercased()
|
|
555
|
+
guard let peripheral = connectedPeripherals[normalizedDeviceUUID],
|
|
556
|
+
let characteristic = findCharacteristic(
|
|
557
|
+
deviceUUID: deviceUUID,
|
|
558
|
+
serviceUUID: serviceUUID,
|
|
559
|
+
charUUID: charUUID
|
|
560
|
+
) else {
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
let key = "\(normalizedDeviceUUID):\(characteristic.uuid.uuidString.uppercased())"
|
|
565
|
+
subscriptions.remove(key)
|
|
566
|
+
peripheral.setNotifyValue(false, for: characteristic)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// MARK: - Service Discovery Methods
|
|
570
|
+
|
|
571
|
+
/// Discover services for a connected peripheral
|
|
572
|
+
/// - Parameters:
|
|
573
|
+
/// - uuid: The peripheral's UUID string
|
|
574
|
+
/// - completion: Callback with success/failure result
|
|
575
|
+
func discoverServices(uuid: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
576
|
+
let normalizedUUID = uuid.uppercased()
|
|
577
|
+
guard let peripheral = connectedPeripherals[normalizedUUID] else {
|
|
578
|
+
completion(.failure(BLEError.notConnected))
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
pendingServiceDiscovery[normalizedUUID] = completion
|
|
583
|
+
peripheral.discoverServices(nil) // Discover all services
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/// Get discovered services and characteristics for a connected peripheral
|
|
587
|
+
/// - Parameter uuid: The peripheral's UUID string
|
|
588
|
+
/// - Returns: Array of service dictionaries with nested characteristics, or nil if not found
|
|
589
|
+
func getServices(uuid: String) -> [[String: Any]]? {
|
|
590
|
+
let normalizedUUID = uuid.uppercased()
|
|
591
|
+
guard let peripheral = connectedPeripherals[normalizedUUID],
|
|
592
|
+
let services = peripheral.services else {
|
|
593
|
+
return nil
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return services.map { service in
|
|
597
|
+
var serviceDict: [String: Any] = [
|
|
598
|
+
"uuid": service.uuid.uuidString,
|
|
599
|
+
"primary": service.isPrimary
|
|
600
|
+
]
|
|
601
|
+
|
|
602
|
+
let characteristics: [[String: Any]] = (service.characteristics ?? []).map { char in
|
|
603
|
+
[
|
|
604
|
+
"uuid": char.uuid.uuidString,
|
|
605
|
+
"properties": characteristicPropertiesToFlags(char.properties),
|
|
606
|
+
"service_uuid": service.uuid.uuidString
|
|
607
|
+
]
|
|
608
|
+
}
|
|
609
|
+
serviceDict["characteristics"] = characteristics
|
|
610
|
+
|
|
611
|
+
return serviceDict
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/// Convert CBCharacteristicProperties to array of string flags
|
|
616
|
+
private func characteristicPropertiesToFlags(_ properties: CBCharacteristicProperties) -> [String] {
|
|
617
|
+
var flags: [String] = []
|
|
618
|
+
if properties.contains(.read) { flags.append("read") }
|
|
619
|
+
if properties.contains(.write) { flags.append("write") }
|
|
620
|
+
if properties.contains(.writeWithoutResponse) { flags.append("write-without-response") }
|
|
621
|
+
if properties.contains(.notify) { flags.append("notify") }
|
|
622
|
+
if properties.contains(.indicate) { flags.append("indicate") }
|
|
623
|
+
if properties.contains(.broadcast) { flags.append("broadcast") }
|
|
624
|
+
if properties.contains(.authenticatedSignedWrites) { flags.append("authenticated-signed-writes") }
|
|
625
|
+
if properties.contains(.extendedProperties) { flags.append("extended-properties") }
|
|
626
|
+
return flags
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// MARK: - CBPeripheralDelegate
|
|
631
|
+
|
|
632
|
+
extension BLEManager: CBPeripheralDelegate {
|
|
633
|
+
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
634
|
+
let uuid = peripheral.identifier.uuidString.uppercased()
|
|
635
|
+
|
|
636
|
+
if let error = error {
|
|
637
|
+
if let completion = pendingServiceDiscovery.removeValue(forKey: uuid) {
|
|
638
|
+
completion(.failure(error))
|
|
639
|
+
}
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Discover characteristics for each service
|
|
644
|
+
guard let services = peripheral.services, !services.isEmpty else {
|
|
645
|
+
// No services found, complete immediately
|
|
646
|
+
if let completion = pendingServiceDiscovery.removeValue(forKey: uuid) {
|
|
647
|
+
completion(.success(()))
|
|
648
|
+
}
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
pendingCharacteristicDiscovery[uuid] = services.count
|
|
653
|
+
|
|
654
|
+
for service in services {
|
|
655
|
+
peripheral.discoverCharacteristics(nil, for: service)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
660
|
+
let uuid = peripheral.identifier.uuidString.uppercased()
|
|
661
|
+
|
|
662
|
+
// Decrement pending count
|
|
663
|
+
if var remaining = pendingCharacteristicDiscovery[uuid] {
|
|
664
|
+
remaining -= 1
|
|
665
|
+
pendingCharacteristicDiscovery[uuid] = remaining
|
|
666
|
+
|
|
667
|
+
// All services discovered?
|
|
668
|
+
if remaining == 0 {
|
|
669
|
+
pendingCharacteristicDiscovery.removeValue(forKey: uuid)
|
|
670
|
+
if let completion = pendingServiceDiscovery.removeValue(forKey: uuid) {
|
|
671
|
+
completion(.success(()))
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// MARK: - GATT Operation Callbacks
|
|
678
|
+
|
|
679
|
+
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
680
|
+
let deviceUUID = peripheral.identifier.uuidString.uppercased()
|
|
681
|
+
let charUUID = characteristic.uuid.uuidString.uppercased()
|
|
682
|
+
let key = "\(deviceUUID):\(charUUID)"
|
|
683
|
+
|
|
684
|
+
// Check if this is a read response
|
|
685
|
+
if let completion = pendingReads.removeValue(forKey: key) {
|
|
686
|
+
if let error = error {
|
|
687
|
+
completion(.failure(error))
|
|
688
|
+
} else if let value = characteristic.value {
|
|
689
|
+
completion(.success(value))
|
|
690
|
+
} else {
|
|
691
|
+
completion(.success(Data()))
|
|
692
|
+
}
|
|
693
|
+
return
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Otherwise it's a notification
|
|
697
|
+
if subscriptions.contains(key) {
|
|
698
|
+
let value = characteristic.value ?? Data()
|
|
699
|
+
let event = Event(
|
|
700
|
+
method: "notification",
|
|
701
|
+
params: [
|
|
702
|
+
"device_uuid": AnyCodable(deviceUUID),
|
|
703
|
+
"service_uuid": AnyCodable(characteristic.service?.uuid.uuidString.uppercased() ?? ""),
|
|
704
|
+
"char_uuid": AnyCodable(charUUID),
|
|
705
|
+
"value": AnyCodable(Array(value).map { Int($0) })
|
|
706
|
+
]
|
|
707
|
+
)
|
|
708
|
+
onEvent?(event)
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
713
|
+
let key = "\(peripheral.identifier.uuidString.uppercased()):\(characteristic.uuid.uuidString.uppercased())"
|
|
714
|
+
if let completion = pendingWrites.removeValue(forKey: key) {
|
|
715
|
+
if let error = error {
|
|
716
|
+
completion(.failure(error))
|
|
717
|
+
} else {
|
|
718
|
+
completion(.success(()))
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
|
724
|
+
let key = "\(peripheral.identifier.uuidString.uppercased()):\(characteristic.uuid.uuidString.uppercased())"
|
|
725
|
+
if let completion = pendingWrites.removeValue(forKey: key) {
|
|
726
|
+
if let error = error {
|
|
727
|
+
completion(.failure(error))
|
|
728
|
+
} else {
|
|
729
|
+
completion(.success(()))
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// MARK: - BLE Errors
|
|
736
|
+
|
|
737
|
+
/// Errors specific to BLE operations
|
|
738
|
+
enum BLEError: Error {
|
|
739
|
+
case notPoweredOn
|
|
740
|
+
case notConnected
|
|
741
|
+
case timeout
|
|
742
|
+
case invalidUUID(String)
|
|
743
|
+
case deviceNotFound
|
|
744
|
+
case connectionFailed
|
|
745
|
+
case serviceDiscoveryFailed
|
|
746
|
+
case characteristicNotFound
|
|
747
|
+
|
|
748
|
+
var localizedDescription: String {
|
|
749
|
+
switch self {
|
|
750
|
+
case .notPoweredOn:
|
|
751
|
+
return "Bluetooth not powered on"
|
|
752
|
+
case .notConnected:
|
|
753
|
+
return "Not connected to device"
|
|
754
|
+
case .timeout:
|
|
755
|
+
return "Operation timed out"
|
|
756
|
+
case .invalidUUID(let uuid):
|
|
757
|
+
return "Invalid UUID: \(uuid)"
|
|
758
|
+
case .deviceNotFound:
|
|
759
|
+
return "Device not found (must scan first)"
|
|
760
|
+
case .connectionFailed:
|
|
761
|
+
return "Connection failed"
|
|
762
|
+
case .serviceDiscoveryFailed:
|
|
763
|
+
return "Service discovery failed"
|
|
764
|
+
case .characteristicNotFound:
|
|
765
|
+
return "Characteristic not found"
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// MARK: - BLE Error Codes (for JSON-RPC responses)
|
|
771
|
+
|
|
772
|
+
/// Application-specific error codes (outside JSON-RPC reserved range)
|
|
773
|
+
enum BLEErrorCode {
|
|
774
|
+
static let notPoweredOn = -1
|
|
775
|
+
static let notConnected = -2
|
|
776
|
+
static let timeout = -3
|
|
777
|
+
static let invalidUUID = -4
|
|
778
|
+
static let operationFailed = -5
|
|
779
|
+
static let deviceNotFound = -6
|
|
780
|
+
static let connectionFailed = -7
|
|
781
|
+
static let serviceDiscoveryFailed = -8
|
|
782
|
+
static let characteristicNotFound = -9
|
|
783
|
+
}
|