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,645 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// RBLEHelper - macOS CoreBluetooth bridge for rble gem
|
|
4
|
+
///
|
|
5
|
+
/// Communication protocol:
|
|
6
|
+
/// - Reads JSON requests from stdin (one per line)
|
|
7
|
+
/// - Writes JSON responses to stdout (one per line)
|
|
8
|
+
/// - Async events (device_discovered, state_changed) written to stdout as JSON lines
|
|
9
|
+
/// - All non-JSON output goes to stderr
|
|
10
|
+
|
|
11
|
+
// MARK: - Global State
|
|
12
|
+
|
|
13
|
+
/// Shared BLE manager instance
|
|
14
|
+
let bleManager = BLEManager()
|
|
15
|
+
|
|
16
|
+
// MARK: - Output Functions
|
|
17
|
+
|
|
18
|
+
/// Write a response to stdout as a single JSON line (thread-safe)
|
|
19
|
+
func writeResponse(_ response: Response) {
|
|
20
|
+
let encoder = JSONEncoder()
|
|
21
|
+
encoder.outputFormatting = [] // Compact output, no pretty printing
|
|
22
|
+
do {
|
|
23
|
+
let jsonData = try encoder.encode(response)
|
|
24
|
+
if let jsonString = String(data: jsonData, encoding: .utf8) {
|
|
25
|
+
print(jsonString)
|
|
26
|
+
fflush(stdout)
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// If encoding fails, write a minimal error response
|
|
30
|
+
fputs("{\"id\":-1,\"error\":{\"code\":-32603,\"message\":\"Internal error: failed to encode response\"}}\n", stdout)
|
|
31
|
+
fflush(stdout)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Write an event to stdout as a single JSON line (thread-safe)
|
|
36
|
+
func writeEvent(_ event: Event) {
|
|
37
|
+
let encoder = JSONEncoder()
|
|
38
|
+
encoder.outputFormatting = [] // Compact output, no pretty printing
|
|
39
|
+
do {
|
|
40
|
+
let jsonData = try encoder.encode(event)
|
|
41
|
+
if let jsonString = String(data: jsonData, encoding: .utf8) {
|
|
42
|
+
print(jsonString)
|
|
43
|
+
fflush(stdout)
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
fputs("{\"method\":\"error\",\"params\":{\"message\":\"Failed to encode event\"}}\n", stdout)
|
|
47
|
+
fflush(stdout)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Create an error response for parse errors (no valid request ID available)
|
|
52
|
+
func createParseErrorResponse() -> Response {
|
|
53
|
+
return Response.error(
|
|
54
|
+
id: -1,
|
|
55
|
+
code: ErrorCode.parseError,
|
|
56
|
+
message: "Parse error: invalid JSON"
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// MARK: - Request Handlers
|
|
61
|
+
|
|
62
|
+
/// Handle a parsed request and return a response
|
|
63
|
+
/// For sync methods, returns a Response directly
|
|
64
|
+
/// For async methods, returns nil and calls writeResponse when complete
|
|
65
|
+
func handleRequest(_ request: Request) -> Response? {
|
|
66
|
+
switch request.method {
|
|
67
|
+
case "adapters":
|
|
68
|
+
handleAdapters(request)
|
|
69
|
+
return nil // Response sent asynchronously after state check
|
|
70
|
+
case "scan_start":
|
|
71
|
+
handleScanStart(request)
|
|
72
|
+
return nil // Response sent asynchronously
|
|
73
|
+
case "scan_stop":
|
|
74
|
+
return handleScanStop(request)
|
|
75
|
+
case "connect":
|
|
76
|
+
handleConnect(request)
|
|
77
|
+
return nil // Response sent asynchronously
|
|
78
|
+
case "disconnect":
|
|
79
|
+
handleDisconnect(request)
|
|
80
|
+
return nil // Response sent asynchronously
|
|
81
|
+
case "discover_services":
|
|
82
|
+
handleDiscoverServices(request)
|
|
83
|
+
return nil // Response sent asynchronously
|
|
84
|
+
case "read_characteristic":
|
|
85
|
+
handleReadCharacteristic(request)
|
|
86
|
+
return nil // Response sent asynchronously
|
|
87
|
+
case "write_characteristic":
|
|
88
|
+
handleWriteCharacteristic(request)
|
|
89
|
+
return nil // Response sent asynchronously
|
|
90
|
+
case "subscribe":
|
|
91
|
+
handleSubscribe(request)
|
|
92
|
+
return nil // Response sent asynchronously
|
|
93
|
+
case "unsubscribe":
|
|
94
|
+
return handleUnsubscribe(request)
|
|
95
|
+
default:
|
|
96
|
+
return Response.error(
|
|
97
|
+
id: request.id,
|
|
98
|
+
code: ErrorCode.methodNotFound,
|
|
99
|
+
message: "Method not found: \(request.method)"
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Handle the "adapters" method - returns Bluetooth adapter state
|
|
105
|
+
/// macOS has a single logical Bluetooth adapter
|
|
106
|
+
/// Waits briefly for CoreBluetooth to determine state if currently unknown
|
|
107
|
+
func handleAdapters(_ request: Request) {
|
|
108
|
+
// Wait for state to be determined (not unknown/resetting)
|
|
109
|
+
bleManager.waitForStateKnown(timeout: 2) { _ in
|
|
110
|
+
let state = bleManager.getState()
|
|
111
|
+
let powered = state == "powered_on"
|
|
112
|
+
|
|
113
|
+
let adapter: [String: Any] = [
|
|
114
|
+
"name": "default",
|
|
115
|
+
"powered": powered,
|
|
116
|
+
"state": state
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
writeResponse(Response.success(
|
|
120
|
+
id: request.id,
|
|
121
|
+
result: ["adapters": AnyCodable([adapter])]
|
|
122
|
+
))
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Handle the "scan_start" method - begins BLE scanning
|
|
127
|
+
/// Optional params:
|
|
128
|
+
/// - service_uuids: Array of service UUID strings to filter by
|
|
129
|
+
/// - allow_duplicates: Boolean to receive repeated advertisements
|
|
130
|
+
func handleScanStart(_ request: Request) {
|
|
131
|
+
// Extract optional parameters
|
|
132
|
+
let serviceUUIDs = request.params?["service_uuids"]?.value as? [String]
|
|
133
|
+
let allowDuplicates = request.params?["allow_duplicates"]?.value as? Bool ?? false
|
|
134
|
+
|
|
135
|
+
// Wait for Bluetooth to be powered on (CoreBluetooth needs time to initialize)
|
|
136
|
+
bleManager.waitForPoweredOn(timeout: 5) { ready in
|
|
137
|
+
if ready {
|
|
138
|
+
do {
|
|
139
|
+
try bleManager.startScan(serviceUUIDs: serviceUUIDs, allowDuplicates: allowDuplicates)
|
|
140
|
+
writeResponse(Response.success(
|
|
141
|
+
id: request.id,
|
|
142
|
+
result: ["status": AnyCodable("started")]
|
|
143
|
+
))
|
|
144
|
+
} catch BLEError.notPoweredOn {
|
|
145
|
+
writeResponse(Response.error(
|
|
146
|
+
id: request.id,
|
|
147
|
+
code: BLEErrorCode.notPoweredOn,
|
|
148
|
+
message: "Bluetooth not powered on",
|
|
149
|
+
data: ["platform_error": "CBManagerState.poweredOff"]
|
|
150
|
+
))
|
|
151
|
+
} catch {
|
|
152
|
+
writeResponse(Response.error(
|
|
153
|
+
id: request.id,
|
|
154
|
+
code: ErrorCode.internalError,
|
|
155
|
+
message: error.localizedDescription
|
|
156
|
+
))
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
writeResponse(Response.error(
|
|
160
|
+
id: request.id,
|
|
161
|
+
code: BLEErrorCode.notPoweredOn,
|
|
162
|
+
message: "Bluetooth not powered on",
|
|
163
|
+
data: ["platform_error": "CBManagerState.poweredOff"]
|
|
164
|
+
))
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// Handle the "scan_stop" method - stops BLE scanning
|
|
170
|
+
func handleScanStop(_ request: Request) -> Response {
|
|
171
|
+
bleManager.stopScan()
|
|
172
|
+
return Response.success(
|
|
173
|
+
id: request.id,
|
|
174
|
+
result: ["status": AnyCodable("stopped")]
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// MARK: - Async Request Handlers (Connection and Service Discovery)
|
|
179
|
+
|
|
180
|
+
/// Handle the "connect" method - connects to a peripheral
|
|
181
|
+
/// Required params:
|
|
182
|
+
/// - uuid: The peripheral's UUID string (must have been discovered first)
|
|
183
|
+
/// Optional params:
|
|
184
|
+
/// - timeout: Connection timeout in seconds (default: 30)
|
|
185
|
+
func handleConnect(_ request: Request) {
|
|
186
|
+
guard let uuid = request.params?["uuid"]?.value as? String else {
|
|
187
|
+
writeResponse(Response.error(
|
|
188
|
+
id: request.id,
|
|
189
|
+
code: ErrorCode.invalidParams,
|
|
190
|
+
message: "Missing required param: uuid"
|
|
191
|
+
))
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let timeout = request.params?["timeout"]?.value as? Int ?? 30
|
|
196
|
+
|
|
197
|
+
// Track timeout
|
|
198
|
+
var timedOut = false
|
|
199
|
+
let timeoutWorkItem = DispatchWorkItem {
|
|
200
|
+
timedOut = true
|
|
201
|
+
writeResponse(Response.error(
|
|
202
|
+
id: request.id,
|
|
203
|
+
code: BLEErrorCode.timeout,
|
|
204
|
+
message: "Connection timeout"
|
|
205
|
+
))
|
|
206
|
+
}
|
|
207
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: timeoutWorkItem)
|
|
208
|
+
|
|
209
|
+
bleManager.connect(uuid: uuid) { result in
|
|
210
|
+
// Cancel timeout if we got a result
|
|
211
|
+
timeoutWorkItem.cancel()
|
|
212
|
+
guard !timedOut else { return }
|
|
213
|
+
|
|
214
|
+
switch result {
|
|
215
|
+
case .success:
|
|
216
|
+
writeResponse(Response.success(
|
|
217
|
+
id: request.id,
|
|
218
|
+
result: ["status": AnyCodable("connected")]
|
|
219
|
+
))
|
|
220
|
+
case .failure(let error):
|
|
221
|
+
let code: Int
|
|
222
|
+
if let bleError = error as? BLEError {
|
|
223
|
+
switch bleError {
|
|
224
|
+
case .deviceNotFound: code = BLEErrorCode.deviceNotFound
|
|
225
|
+
case .notPoweredOn: code = BLEErrorCode.notPoweredOn
|
|
226
|
+
case .connectionFailed: code = BLEErrorCode.connectionFailed
|
|
227
|
+
default: code = BLEErrorCode.operationFailed
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
code = BLEErrorCode.operationFailed
|
|
231
|
+
}
|
|
232
|
+
writeResponse(Response.error(
|
|
233
|
+
id: request.id,
|
|
234
|
+
code: code,
|
|
235
|
+
message: error.localizedDescription
|
|
236
|
+
))
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/// Handle the "disconnect" method - disconnects from a peripheral
|
|
242
|
+
/// Required params:
|
|
243
|
+
/// - uuid: The peripheral's UUID string
|
|
244
|
+
func handleDisconnect(_ request: Request) {
|
|
245
|
+
guard let uuid = request.params?["uuid"]?.value as? String else {
|
|
246
|
+
writeResponse(Response.error(
|
|
247
|
+
id: request.id,
|
|
248
|
+
code: ErrorCode.invalidParams,
|
|
249
|
+
message: "Missing required param: uuid"
|
|
250
|
+
))
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Disconnect with a short timeout (5 seconds)
|
|
255
|
+
var timedOut = false
|
|
256
|
+
let timeoutWorkItem = DispatchWorkItem {
|
|
257
|
+
timedOut = true
|
|
258
|
+
// Even if timeout, respond with disconnected since we initiated the disconnect
|
|
259
|
+
writeResponse(Response.success(
|
|
260
|
+
id: request.id,
|
|
261
|
+
result: ["status": AnyCodable("disconnected")]
|
|
262
|
+
))
|
|
263
|
+
}
|
|
264
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5), execute: timeoutWorkItem)
|
|
265
|
+
|
|
266
|
+
bleManager.disconnect(uuid: uuid) {
|
|
267
|
+
timeoutWorkItem.cancel()
|
|
268
|
+
guard !timedOut else { return }
|
|
269
|
+
|
|
270
|
+
writeResponse(Response.success(
|
|
271
|
+
id: request.id,
|
|
272
|
+
result: ["status": AnyCodable("disconnected")]
|
|
273
|
+
))
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/// Handle the "discover_services" method - discovers services and characteristics
|
|
278
|
+
/// Required params:
|
|
279
|
+
/// - uuid: The peripheral's UUID string (must be connected)
|
|
280
|
+
/// Optional params:
|
|
281
|
+
/// - timeout: Discovery timeout in seconds (default: 30)
|
|
282
|
+
func handleDiscoverServices(_ request: Request) {
|
|
283
|
+
guard let uuid = request.params?["uuid"]?.value as? String else {
|
|
284
|
+
writeResponse(Response.error(
|
|
285
|
+
id: request.id,
|
|
286
|
+
code: ErrorCode.invalidParams,
|
|
287
|
+
message: "Missing required param: uuid"
|
|
288
|
+
))
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let timeout = request.params?["timeout"]?.value as? Int ?? 30
|
|
293
|
+
|
|
294
|
+
// Track timeout
|
|
295
|
+
var timedOut = false
|
|
296
|
+
let timeoutWorkItem = DispatchWorkItem {
|
|
297
|
+
timedOut = true
|
|
298
|
+
writeResponse(Response.error(
|
|
299
|
+
id: request.id,
|
|
300
|
+
code: BLEErrorCode.timeout,
|
|
301
|
+
message: "Service discovery timeout"
|
|
302
|
+
))
|
|
303
|
+
}
|
|
304
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: timeoutWorkItem)
|
|
305
|
+
|
|
306
|
+
bleManager.discoverServices(uuid: uuid) { result in
|
|
307
|
+
timeoutWorkItem.cancel()
|
|
308
|
+
guard !timedOut else { return }
|
|
309
|
+
|
|
310
|
+
switch result {
|
|
311
|
+
case .success:
|
|
312
|
+
if let services = bleManager.getServices(uuid: uuid) {
|
|
313
|
+
writeResponse(Response.success(
|
|
314
|
+
id: request.id,
|
|
315
|
+
result: ["services": AnyCodable(services)]
|
|
316
|
+
))
|
|
317
|
+
} else {
|
|
318
|
+
writeResponse(Response.error(
|
|
319
|
+
id: request.id,
|
|
320
|
+
code: BLEErrorCode.serviceDiscoveryFailed,
|
|
321
|
+
message: "No services found"
|
|
322
|
+
))
|
|
323
|
+
}
|
|
324
|
+
case .failure(let error):
|
|
325
|
+
let code: Int
|
|
326
|
+
if let bleError = error as? BLEError {
|
|
327
|
+
switch bleError {
|
|
328
|
+
case .notConnected: code = BLEErrorCode.notConnected
|
|
329
|
+
case .serviceDiscoveryFailed: code = BLEErrorCode.serviceDiscoveryFailed
|
|
330
|
+
default: code = BLEErrorCode.operationFailed
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
code = BLEErrorCode.operationFailed
|
|
334
|
+
}
|
|
335
|
+
writeResponse(Response.error(
|
|
336
|
+
id: request.id,
|
|
337
|
+
code: code,
|
|
338
|
+
message: error.localizedDescription
|
|
339
|
+
))
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// MARK: - GATT Operation Handlers
|
|
345
|
+
|
|
346
|
+
/// Handle the "read_characteristic" method - reads a characteristic value
|
|
347
|
+
/// Required params:
|
|
348
|
+
/// - device_uuid: The peripheral's UUID string
|
|
349
|
+
/// - service_uuid: The service UUID (short or full)
|
|
350
|
+
/// - char_uuid: The characteristic UUID (short or full)
|
|
351
|
+
/// Optional params:
|
|
352
|
+
/// - timeout: Read timeout in seconds (default: 30)
|
|
353
|
+
func handleReadCharacteristic(_ request: Request) {
|
|
354
|
+
guard let deviceUUID = request.params?["device_uuid"]?.value as? String,
|
|
355
|
+
let serviceUUID = request.params?["service_uuid"]?.value as? String,
|
|
356
|
+
let charUUID = request.params?["char_uuid"]?.value as? String else {
|
|
357
|
+
writeResponse(Response.error(
|
|
358
|
+
id: request.id,
|
|
359
|
+
code: ErrorCode.invalidParams,
|
|
360
|
+
message: "Missing required params: device_uuid, service_uuid, char_uuid"
|
|
361
|
+
))
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let timeout = request.params?["timeout"]?.value as? Int ?? 30
|
|
366
|
+
|
|
367
|
+
// Track timeout
|
|
368
|
+
var timedOut = false
|
|
369
|
+
let timeoutWorkItem = DispatchWorkItem {
|
|
370
|
+
timedOut = true
|
|
371
|
+
writeResponse(Response.error(
|
|
372
|
+
id: request.id,
|
|
373
|
+
code: BLEErrorCode.timeout,
|
|
374
|
+
message: "Read timeout"
|
|
375
|
+
))
|
|
376
|
+
}
|
|
377
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: timeoutWorkItem)
|
|
378
|
+
|
|
379
|
+
bleManager.readCharacteristic(
|
|
380
|
+
deviceUUID: deviceUUID,
|
|
381
|
+
serviceUUID: serviceUUID,
|
|
382
|
+
charUUID: charUUID
|
|
383
|
+
) { result in
|
|
384
|
+
// Cancel timeout if we got a result
|
|
385
|
+
timeoutWorkItem.cancel()
|
|
386
|
+
guard !timedOut else { return }
|
|
387
|
+
|
|
388
|
+
switch result {
|
|
389
|
+
case .success(let data):
|
|
390
|
+
writeResponse(Response.success(
|
|
391
|
+
id: request.id,
|
|
392
|
+
result: ["value": AnyCodable(Array(data).map { Int($0) })]
|
|
393
|
+
))
|
|
394
|
+
case .failure(let error):
|
|
395
|
+
let code: Int
|
|
396
|
+
if let bleError = error as? BLEError {
|
|
397
|
+
switch bleError {
|
|
398
|
+
case .notConnected: code = BLEErrorCode.notConnected
|
|
399
|
+
case .characteristicNotFound: code = BLEErrorCode.characteristicNotFound
|
|
400
|
+
default: code = BLEErrorCode.operationFailed
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
code = BLEErrorCode.operationFailed
|
|
404
|
+
}
|
|
405
|
+
writeResponse(Response.error(
|
|
406
|
+
id: request.id,
|
|
407
|
+
code: code,
|
|
408
|
+
message: error.localizedDescription
|
|
409
|
+
))
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/// Handle the "write_characteristic" method - writes a value to a characteristic
|
|
415
|
+
/// Required params:
|
|
416
|
+
/// - device_uuid: The peripheral's UUID string
|
|
417
|
+
/// - service_uuid: The service UUID (short or full)
|
|
418
|
+
/// - char_uuid: The characteristic UUID (short or full)
|
|
419
|
+
/// - value: Array of bytes to write
|
|
420
|
+
/// Optional params:
|
|
421
|
+
/// - response: Whether to request write confirmation (default: true)
|
|
422
|
+
/// - timeout: Write timeout in seconds (default: 30)
|
|
423
|
+
func handleWriteCharacteristic(_ request: Request) {
|
|
424
|
+
guard let deviceUUID = request.params?["device_uuid"]?.value as? String,
|
|
425
|
+
let serviceUUID = request.params?["service_uuid"]?.value as? String,
|
|
426
|
+
let charUUID = request.params?["char_uuid"]?.value as? String,
|
|
427
|
+
let valueArray = request.params?["value"]?.value as? [Int] else {
|
|
428
|
+
writeResponse(Response.error(
|
|
429
|
+
id: request.id,
|
|
430
|
+
code: ErrorCode.invalidParams,
|
|
431
|
+
message: "Missing required params: device_uuid, service_uuid, char_uuid, value"
|
|
432
|
+
))
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
let withResponse = request.params?["response"]?.value as? Bool ?? true
|
|
437
|
+
let data = Data(valueArray.map { UInt8(clamping: $0) })
|
|
438
|
+
let timeout = request.params?["timeout"]?.value as? Int ?? 30
|
|
439
|
+
|
|
440
|
+
// Track timeout
|
|
441
|
+
var timedOut = false
|
|
442
|
+
let timeoutWorkItem = DispatchWorkItem {
|
|
443
|
+
timedOut = true
|
|
444
|
+
writeResponse(Response.error(
|
|
445
|
+
id: request.id,
|
|
446
|
+
code: BLEErrorCode.timeout,
|
|
447
|
+
message: "Write timeout"
|
|
448
|
+
))
|
|
449
|
+
}
|
|
450
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: timeoutWorkItem)
|
|
451
|
+
|
|
452
|
+
bleManager.writeCharacteristic(
|
|
453
|
+
deviceUUID: deviceUUID,
|
|
454
|
+
serviceUUID: serviceUUID,
|
|
455
|
+
charUUID: charUUID,
|
|
456
|
+
data: data,
|
|
457
|
+
withResponse: withResponse
|
|
458
|
+
) { result in
|
|
459
|
+
// Cancel timeout if we got a result
|
|
460
|
+
timeoutWorkItem.cancel()
|
|
461
|
+
guard !timedOut else { return }
|
|
462
|
+
|
|
463
|
+
switch result {
|
|
464
|
+
case .success:
|
|
465
|
+
writeResponse(Response.success(
|
|
466
|
+
id: request.id,
|
|
467
|
+
result: ["status": AnyCodable("written")]
|
|
468
|
+
))
|
|
469
|
+
case .failure(let error):
|
|
470
|
+
let code: Int
|
|
471
|
+
if let bleError = error as? BLEError {
|
|
472
|
+
switch bleError {
|
|
473
|
+
case .notConnected: code = BLEErrorCode.notConnected
|
|
474
|
+
case .characteristicNotFound: code = BLEErrorCode.characteristicNotFound
|
|
475
|
+
default: code = BLEErrorCode.operationFailed
|
|
476
|
+
}
|
|
477
|
+
} else {
|
|
478
|
+
code = BLEErrorCode.operationFailed
|
|
479
|
+
}
|
|
480
|
+
writeResponse(Response.error(
|
|
481
|
+
id: request.id,
|
|
482
|
+
code: code,
|
|
483
|
+
message: error.localizedDescription
|
|
484
|
+
))
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/// Handle the "subscribe" method - subscribes to characteristic notifications
|
|
490
|
+
/// Required params:
|
|
491
|
+
/// - device_uuid: The peripheral's UUID string
|
|
492
|
+
/// - service_uuid: The service UUID (short or full)
|
|
493
|
+
/// - char_uuid: The characteristic UUID (short or full)
|
|
494
|
+
/// Optional params:
|
|
495
|
+
/// - timeout: Subscribe timeout in seconds (default: 30)
|
|
496
|
+
func handleSubscribe(_ request: Request) {
|
|
497
|
+
guard let deviceUUID = request.params?["device_uuid"]?.value as? String,
|
|
498
|
+
let serviceUUID = request.params?["service_uuid"]?.value as? String,
|
|
499
|
+
let charUUID = request.params?["char_uuid"]?.value as? String else {
|
|
500
|
+
writeResponse(Response.error(
|
|
501
|
+
id: request.id,
|
|
502
|
+
code: ErrorCode.invalidParams,
|
|
503
|
+
message: "Missing required params: device_uuid, service_uuid, char_uuid"
|
|
504
|
+
))
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let timeout = request.params?["timeout"]?.value as? Int ?? 30
|
|
509
|
+
|
|
510
|
+
// Track timeout
|
|
511
|
+
var timedOut = false
|
|
512
|
+
let timeoutWorkItem = DispatchWorkItem {
|
|
513
|
+
timedOut = true
|
|
514
|
+
writeResponse(Response.error(
|
|
515
|
+
id: request.id,
|
|
516
|
+
code: BLEErrorCode.timeout,
|
|
517
|
+
message: "Subscribe timeout"
|
|
518
|
+
))
|
|
519
|
+
}
|
|
520
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: timeoutWorkItem)
|
|
521
|
+
|
|
522
|
+
bleManager.subscribe(
|
|
523
|
+
deviceUUID: deviceUUID,
|
|
524
|
+
serviceUUID: serviceUUID,
|
|
525
|
+
charUUID: charUUID
|
|
526
|
+
) { result in
|
|
527
|
+
// Cancel timeout if we got a result
|
|
528
|
+
timeoutWorkItem.cancel()
|
|
529
|
+
guard !timedOut else { return }
|
|
530
|
+
|
|
531
|
+
switch result {
|
|
532
|
+
case .success:
|
|
533
|
+
writeResponse(Response.success(
|
|
534
|
+
id: request.id,
|
|
535
|
+
result: ["status": AnyCodable("subscribed")]
|
|
536
|
+
))
|
|
537
|
+
case .failure(let error):
|
|
538
|
+
let code: Int
|
|
539
|
+
if let bleError = error as? BLEError {
|
|
540
|
+
switch bleError {
|
|
541
|
+
case .notConnected: code = BLEErrorCode.notConnected
|
|
542
|
+
case .characteristicNotFound: code = BLEErrorCode.characteristicNotFound
|
|
543
|
+
default: code = BLEErrorCode.operationFailed
|
|
544
|
+
}
|
|
545
|
+
} else {
|
|
546
|
+
code = BLEErrorCode.operationFailed
|
|
547
|
+
}
|
|
548
|
+
writeResponse(Response.error(
|
|
549
|
+
id: request.id,
|
|
550
|
+
code: code,
|
|
551
|
+
message: error.localizedDescription
|
|
552
|
+
))
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/// Handle the "unsubscribe" method - unsubscribes from characteristic notifications
|
|
558
|
+
/// Required params:
|
|
559
|
+
/// - device_uuid: The peripheral's UUID string
|
|
560
|
+
/// - service_uuid: The service UUID (short or full)
|
|
561
|
+
/// - char_uuid: The characteristic UUID (short or full)
|
|
562
|
+
func handleUnsubscribe(_ request: Request) -> Response {
|
|
563
|
+
guard let deviceUUID = request.params?["device_uuid"]?.value as? String,
|
|
564
|
+
let serviceUUID = request.params?["service_uuid"]?.value as? String,
|
|
565
|
+
let charUUID = request.params?["char_uuid"]?.value as? String else {
|
|
566
|
+
return Response.error(
|
|
567
|
+
id: request.id,
|
|
568
|
+
code: ErrorCode.invalidParams,
|
|
569
|
+
message: "Missing required params: device_uuid, service_uuid, char_uuid"
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
bleManager.unsubscribe(
|
|
574
|
+
deviceUUID: deviceUUID,
|
|
575
|
+
serviceUUID: serviceUUID,
|
|
576
|
+
charUUID: charUUID
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
return Response.success(
|
|
580
|
+
id: request.id,
|
|
581
|
+
result: ["status": AnyCodable("unsubscribed")]
|
|
582
|
+
)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// MARK: - Main Entry Point
|
|
586
|
+
|
|
587
|
+
func main() {
|
|
588
|
+
// Disable stdout buffering for immediate output
|
|
589
|
+
setbuf(stdout, nil)
|
|
590
|
+
|
|
591
|
+
// Setup event handler to write events to stdout
|
|
592
|
+
// Events are dispatched to main queue for thread safety
|
|
593
|
+
bleManager.onEvent = { event in
|
|
594
|
+
DispatchQueue.main.async {
|
|
595
|
+
writeEvent(event)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
let decoder = JSONDecoder()
|
|
600
|
+
|
|
601
|
+
// Read stdin on a background queue so the main RunLoop can process CoreBluetooth callbacks
|
|
602
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
603
|
+
while let line = readLine() {
|
|
604
|
+
// Skip empty lines
|
|
605
|
+
guard !line.isEmpty else { continue }
|
|
606
|
+
|
|
607
|
+
do {
|
|
608
|
+
// Parse JSON request
|
|
609
|
+
guard let jsonData = line.data(using: .utf8) else {
|
|
610
|
+
DispatchQueue.main.async {
|
|
611
|
+
writeResponse(createParseErrorResponse())
|
|
612
|
+
}
|
|
613
|
+
continue
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
let request = try decoder.decode(Request.self, from: jsonData)
|
|
617
|
+
|
|
618
|
+
// Handle request on main queue for thread safety with CoreBluetooth
|
|
619
|
+
DispatchQueue.main.async {
|
|
620
|
+
if let response = handleRequest(request) {
|
|
621
|
+
// Sync response - write immediately
|
|
622
|
+
writeResponse(response)
|
|
623
|
+
}
|
|
624
|
+
// Async responses (nil) are written by their handlers
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
} catch {
|
|
628
|
+
// JSON parse error or decoding error
|
|
629
|
+
DispatchQueue.main.async {
|
|
630
|
+
writeResponse(createParseErrorResponse())
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// stdin closed (EOF) - exit cleanly
|
|
636
|
+
exit(0)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Run main RunLoop for CoreBluetooth callbacks
|
|
640
|
+
// This keeps the process alive and allows CBCentralManagerDelegate methods to be called
|
|
641
|
+
RunLoop.main.run()
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Run the main loop
|
|
645
|
+
main()
|