@matter-server/ble-proxy 1.4.1-alpha.0-20260813-eaf19f2 → 1.4.1-alpha.0-20261009-dea00a8
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.
- package/dist/esm/BleProxyConnection.d.ts.map +1 -1
- package/dist/esm/BleProxyConnection.js +47 -2
- package/dist/esm/BleProxyConnection.js.map +1 -1
- package/dist/esm/BleProxyHandler.d.ts.map +1 -1
- package/dist/esm/BleProxyHandler.js +26 -6
- package/dist/esm/BleProxyHandler.js.map +1 -1
- package/dist/esm/BleProxyProtocol.d.ts +6 -0
- package/dist/esm/BleProxyProtocol.d.ts.map +1 -1
- package/dist/esm/BleProxyProtocol.js +11 -0
- package/dist/esm/BleProxyProtocol.js.map +1 -1
- package/dist/esm/ProxyBleChannel.d.ts.map +1 -1
- package/dist/esm/ProxyBleChannel.js +2 -1
- package/dist/esm/ProxyBleChannel.js.map +1 -1
- package/dist/esm/example/NobleBleProxyClient.d.ts +63 -1
- package/dist/esm/example/NobleBleProxyClient.d.ts.map +1 -1
- package/dist/esm/example/NobleBleProxyClient.js +346 -59
- package/dist/esm/example/NobleBleProxyClient.js.map +1 -1
- package/package.json +7 -7
- package/src/BleProxyConnection.ts +55 -2
- package/src/BleProxyHandler.ts +33 -8
- package/src/BleProxyProtocol.ts +11 -0
- package/src/ProxyBleChannel.ts +1 -0
- package/src/example/NobleBleProxyClient.ts +481 -76
|
@@ -7,9 +7,12 @@ import { WebSocket } from "ws";
|
|
|
7
7
|
import {
|
|
8
8
|
BLE_PROXY_PROTOCOL_VERSION,
|
|
9
9
|
BinaryFrameOpcode,
|
|
10
|
+
BleProxyErrorCode,
|
|
10
11
|
decodeBinaryFrame,
|
|
11
12
|
encodeBinaryFrame
|
|
12
13
|
} from "../BleProxyProtocol.js";
|
|
14
|
+
const CONNECT_DISCOVERY_TIMEOUT_MS = 5e3;
|
|
15
|
+
const SCAN_OPERATION_TIMEOUT_MS = 1e4;
|
|
13
16
|
function withTimeout(promise, ms, message) {
|
|
14
17
|
return Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms))]);
|
|
15
18
|
}
|
|
@@ -38,14 +41,27 @@ class NobleBleProxyClient {
|
|
|
38
41
|
#connections = /* @__PURE__ */ new Map();
|
|
39
42
|
#nextHandle = 1;
|
|
40
43
|
#discoveredPeripherals = /* @__PURE__ */ new Map();
|
|
44
|
+
#discoveryWaiters = /* @__PURE__ */ new Map();
|
|
41
45
|
#lastDiscoverFingerprint = /* @__PURE__ */ new Map();
|
|
46
|
+
#desiredScan;
|
|
47
|
+
#scanPauses = 0;
|
|
48
|
+
#scanGeneration = 0;
|
|
49
|
+
#scanOperations = Promise.resolve();
|
|
50
|
+
#discoverListenerInstalled = false;
|
|
51
|
+
#scanOperationTimeoutMs;
|
|
42
52
|
#commandHandlers = /* @__PURE__ */ new Map();
|
|
43
53
|
#closing = false;
|
|
44
|
-
constructor(serverUrl, hciId) {
|
|
54
|
+
constructor(serverUrl, hciId, options = {}) {
|
|
45
55
|
this.#serverUrl = serverUrl;
|
|
46
56
|
this.#hciId = hciId;
|
|
57
|
+
this.#noble = options.noble;
|
|
58
|
+
this.#scanOperationTimeoutMs = options.scanOperationTimeoutMs ?? SCAN_OPERATION_TIMEOUT_MS;
|
|
47
59
|
this.#registerCommandHandlers();
|
|
48
60
|
}
|
|
61
|
+
/** Command names this client implements. Must cover every `BleProxyCommand` value. */
|
|
62
|
+
get supportedCommands() {
|
|
63
|
+
return [...this.#commandHandlers.keys()];
|
|
64
|
+
}
|
|
49
65
|
async connect() {
|
|
50
66
|
await this.#loadNoble();
|
|
51
67
|
return new Promise((resolve, reject) => {
|
|
@@ -60,7 +76,17 @@ class NobleBleProxyClient {
|
|
|
60
76
|
this.#handleBinaryFrame(Buffer.from(data));
|
|
61
77
|
return;
|
|
62
78
|
}
|
|
63
|
-
|
|
79
|
+
let msg;
|
|
80
|
+
try {
|
|
81
|
+
msg = JSON.parse(data.toString());
|
|
82
|
+
} catch {
|
|
83
|
+
warn("Received invalid JSON from the server");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (typeof msg !== "object" || msg === null) {
|
|
87
|
+
warn("Ignoring non-object JSON message from the server");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
64
90
|
if (!handshakeComplete) {
|
|
65
91
|
if (msg.type === "hello_response") {
|
|
66
92
|
if (msg.error) {
|
|
@@ -114,10 +140,20 @@ class NobleBleProxyClient {
|
|
|
114
140
|
}
|
|
115
141
|
}
|
|
116
142
|
this.#connections.clear();
|
|
143
|
+
for (const waiters of this.#discoveryWaiters.values()) {
|
|
144
|
+
for (const waiter of waiters) {
|
|
145
|
+
clearTimeout(waiter.timer);
|
|
146
|
+
waiter.resolve(void 0);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
this.#discoveryWaiters.clear();
|
|
117
150
|
this.#noble?.stop();
|
|
118
151
|
this.#ws?.close();
|
|
119
152
|
}
|
|
120
153
|
async #loadNoble() {
|
|
154
|
+
if (this.#noble) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
121
157
|
if (this.#hciId !== void 0) {
|
|
122
158
|
process.env.NOBLE_HCI_DEVICE_ID = this.#hciId.toString();
|
|
123
159
|
}
|
|
@@ -133,7 +169,7 @@ class NobleBleProxyClient {
|
|
|
133
169
|
this.#noble.on("stateChange", (state) => log(`[NOBLE] stateChange: ${state}`));
|
|
134
170
|
}
|
|
135
171
|
#registerCommandHandlers() {
|
|
136
|
-
this.#commandHandlers.set("start_scan", (id,
|
|
172
|
+
this.#commandHandlers.set("start_scan", (id, args) => this.#handleStartScan(id, args));
|
|
137
173
|
this.#commandHandlers.set("stop_scan", (id) => this.#handleStopScan(id));
|
|
138
174
|
this.#commandHandlers.set("connect", (id, args) => this.#handleConnect(id, args));
|
|
139
175
|
this.#commandHandlers.set(
|
|
@@ -160,6 +196,10 @@ class NobleBleProxyClient {
|
|
|
160
196
|
"subscribe_characteristic",
|
|
161
197
|
(id, args) => this.#handleSubscribeCharacteristic(id, args)
|
|
162
198
|
);
|
|
199
|
+
this.#commandHandlers.set(
|
|
200
|
+
"write_and_subscribe",
|
|
201
|
+
(id, args) => this.#handleWriteAndSubscribe(id, args)
|
|
202
|
+
);
|
|
163
203
|
this.#commandHandlers.set(
|
|
164
204
|
"unsubscribe_characteristic",
|
|
165
205
|
(id, args) => this.#handleUnsubscribeCharacteristic(id, args)
|
|
@@ -179,27 +219,56 @@ class NobleBleProxyClient {
|
|
|
179
219
|
const handler = this.#commandHandlers.get(msg.command);
|
|
180
220
|
if (!handler) {
|
|
181
221
|
error(`[\u2192ERR] id=${msg.id} Unknown command: ${msg.command}`);
|
|
182
|
-
this.#sendError(msg.id,
|
|
222
|
+
this.#sendError(msg.id, BleProxyErrorCode.InternalError, `Unknown command: ${msg.command}`);
|
|
183
223
|
return;
|
|
184
224
|
}
|
|
185
225
|
try {
|
|
186
226
|
await handler(msg.id, msg.args ?? {});
|
|
187
227
|
} catch (err) {
|
|
188
228
|
error(`[\u2192ERR] id=${msg.id} ${msg.command} threw: ${err.message}`);
|
|
189
|
-
this.#sendError(msg.id,
|
|
229
|
+
this.#sendError(msg.id, BleProxyErrorCode.InternalError, `${err.message}`);
|
|
190
230
|
}
|
|
191
231
|
}
|
|
192
232
|
// ─── Command Handlers ────────────────────────────────────────────────────
|
|
193
|
-
async #handleStartScan(id) {
|
|
233
|
+
async #handleStartScan(id, args) {
|
|
194
234
|
if (!this.#noble) {
|
|
195
|
-
this.#sendError(id,
|
|
235
|
+
this.#sendError(id, BleProxyErrorCode.BluetoothUnavailable, "Noble not initialized");
|
|
196
236
|
return;
|
|
197
237
|
}
|
|
238
|
+
const config = {
|
|
239
|
+
serviceUuids: args.service_uuids ?? [],
|
|
240
|
+
reportDuplicates: args.allow_duplicates ?? true
|
|
241
|
+
};
|
|
198
242
|
this.#lastDiscoverFingerprint.clear();
|
|
199
|
-
this.#
|
|
243
|
+
this.#installDiscoverListener();
|
|
244
|
+
try {
|
|
245
|
+
await this.#requestScan(config);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
this.#sendError(id, BleProxyErrorCode.InternalError, err.message);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
log(
|
|
251
|
+
`[SCAN] BLE scan started (filter: ${config.serviceUuids.join(",") || "none"} allowDuplicates=${config.reportDuplicates})`
|
|
252
|
+
);
|
|
253
|
+
this.#sendSuccess(id);
|
|
254
|
+
}
|
|
255
|
+
/** Installed once and kept: noble emits `discover` only while a scan is running. */
|
|
256
|
+
#installDiscoverListener() {
|
|
257
|
+
if (this.#discoverListenerInstalled) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
this.#discoverListenerInstalled = true;
|
|
200
261
|
this.#noble.on("discover", (peripheral) => {
|
|
201
262
|
const address = peripheral.address || peripheral.id;
|
|
202
263
|
this.#discoveredPeripherals.set(address, peripheral);
|
|
264
|
+
const waiters = this.#discoveryWaiters.get(address);
|
|
265
|
+
if (waiters) {
|
|
266
|
+
this.#discoveryWaiters.delete(address);
|
|
267
|
+
for (const waiter of waiters) {
|
|
268
|
+
clearTimeout(waiter.timer);
|
|
269
|
+
waiter.resolve(peripheral);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
203
272
|
const serviceData = {};
|
|
204
273
|
for (const sd of peripheral.advertisement.serviceData ?? []) {
|
|
205
274
|
serviceData[sd.uuid] = Buffer.from(sd.data).toString("base64");
|
|
@@ -215,7 +284,7 @@ class NobleBleProxyClient {
|
|
|
215
284
|
};
|
|
216
285
|
const prev = this.#lastDiscoverFingerprint.get(address);
|
|
217
286
|
const changed = !prev || prev.name !== fingerprint.name || prev.connectable !== fingerprint.connectable || prev.serviceUuids !== fingerprint.serviceUuids || prev.serviceData !== fingerprint.serviceData;
|
|
218
|
-
if (!changed) {
|
|
287
|
+
if (!(this.#desiredScan?.reportDuplicates ?? true) && !changed) {
|
|
219
288
|
return;
|
|
220
289
|
}
|
|
221
290
|
this.#lastDiscoverFingerprint.set(address, fingerprint);
|
|
@@ -232,22 +301,50 @@ class NobleBleProxyClient {
|
|
|
232
301
|
};
|
|
233
302
|
this.#sendEvent("device_discovered", event);
|
|
234
303
|
});
|
|
235
|
-
await this.#noble.startScanningAsync(["fff6"], true);
|
|
236
|
-
log("[SCAN] BLE scan started (filter: fff6)");
|
|
237
|
-
this.#sendSuccess(id);
|
|
238
304
|
}
|
|
239
305
|
async #handleStopScan(id) {
|
|
240
|
-
|
|
306
|
+
try {
|
|
307
|
+
await this.#requestScan(void 0);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
this.#sendError(id, BleProxyErrorCode.InternalError, err.message);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
241
312
|
log("[SCAN] BLE scan stopped");
|
|
242
313
|
this.#sendSuccess(id);
|
|
243
314
|
}
|
|
315
|
+
/** Resolves once `address` is advertised, or undefined once the wait times out. */
|
|
316
|
+
#awaitDiscovered(address) {
|
|
317
|
+
const known = this.#discoveredPeripherals.get(address);
|
|
318
|
+
if (known) {
|
|
319
|
+
return Promise.resolve(known);
|
|
320
|
+
}
|
|
321
|
+
if (!this.#desiredScan) {
|
|
322
|
+
return Promise.resolve(void 0);
|
|
323
|
+
}
|
|
324
|
+
log(`[CONN] "${address}" not advertised yet, waiting up to ${CONNECT_DISCOVERY_TIMEOUT_MS}ms`);
|
|
325
|
+
return new Promise((resolve) => {
|
|
326
|
+
const waiters = this.#discoveryWaiters.get(address) ?? /* @__PURE__ */ new Set();
|
|
327
|
+
this.#discoveryWaiters.set(address, waiters);
|
|
328
|
+
const waiter = {
|
|
329
|
+
resolve,
|
|
330
|
+
timer: setTimeout(() => {
|
|
331
|
+
waiters.delete(waiter);
|
|
332
|
+
if (waiters.size === 0) {
|
|
333
|
+
this.#discoveryWaiters.delete(address);
|
|
334
|
+
}
|
|
335
|
+
resolve(void 0);
|
|
336
|
+
}, CONNECT_DISCOVERY_TIMEOUT_MS)
|
|
337
|
+
};
|
|
338
|
+
waiters.add(waiter);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
244
341
|
async #handleConnect(id, args) {
|
|
245
|
-
const peripheral = this.#
|
|
342
|
+
const peripheral = await this.#awaitDiscovered(args.address);
|
|
246
343
|
if (!peripheral) {
|
|
247
344
|
error(
|
|
248
|
-
`[CONN] No peripheral found for address "${args.address}". Known: ${[...this.#discoveredPeripherals.keys()].join(", ")}`
|
|
345
|
+
`[CONN] No peripheral found for address "${args.address}" after ${CONNECT_DISCOVERY_TIMEOUT_MS}ms. Known: ${[...this.#discoveredPeripherals.keys()].join(", ")}`
|
|
249
346
|
);
|
|
250
|
-
this.#sendError(id,
|
|
347
|
+
this.#sendError(id, BleProxyErrorCode.DeviceNotFound, `No device found for address ${args.address}`);
|
|
251
348
|
return;
|
|
252
349
|
}
|
|
253
350
|
const handle = this.#nextHandle++;
|
|
@@ -267,9 +364,10 @@ class NobleBleProxyClient {
|
|
|
267
364
|
};
|
|
268
365
|
peripheral.once("disconnect", disconnectListener);
|
|
269
366
|
log(`[CONN] Connecting to "${args.address}" (state=${peripheral.state})...`);
|
|
367
|
+
let releaseScanPause;
|
|
270
368
|
try {
|
|
271
369
|
log(`[SCAN] pausing scan for connect+interview...`);
|
|
272
|
-
await this.#
|
|
370
|
+
releaseScanPause = await this.#acquireScanPause();
|
|
273
371
|
await peripheral.connectAsync();
|
|
274
372
|
log(`[CONN] Connected handle=${handle} state=${peripheral.state} mtu=${peripheral.mtu ?? "?"}`);
|
|
275
373
|
log(`[GATT] handle=${handle} discoverServicesAsync(["fff6"])...`);
|
|
@@ -297,8 +395,6 @@ class NobleBleProxyClient {
|
|
|
297
395
|
}
|
|
298
396
|
const mtu = peripheral.mtu ?? 23;
|
|
299
397
|
log(`[GATT] handle=${handle} ready mtu=${mtu}`);
|
|
300
|
-
log(`[SCAN] resuming scan after connect+interview...`);
|
|
301
|
-
await this.#noble.startScanningAsync(["fff6"], true);
|
|
302
398
|
this.#sendSuccess(id, { connection_handle: handle, mtu });
|
|
303
399
|
} catch (err) {
|
|
304
400
|
const reason = disconnectedReason ?? err.message;
|
|
@@ -312,16 +408,18 @@ class NobleBleProxyClient {
|
|
|
312
408
|
(disconnectErr) => warn(`[CONN] handle=${handle} cleanup disconnect failed: ${disconnectErr.message}`)
|
|
313
409
|
);
|
|
314
410
|
}
|
|
315
|
-
this.#
|
|
316
|
-
|
|
411
|
+
this.#sendError(id, BleProxyErrorCode.ConnectionFailed, reason);
|
|
412
|
+
} finally {
|
|
413
|
+
log(`[SCAN] resuming scan after connect+interview...`);
|
|
414
|
+
await releaseScanPause?.().catch(
|
|
415
|
+
(scanErr) => warn(`[SCAN] failed to resume scanning after connect: ${scanErr.message}`)
|
|
317
416
|
);
|
|
318
|
-
this.#sendError(id, "internal_error", reason);
|
|
319
417
|
}
|
|
320
418
|
}
|
|
321
419
|
async #handleDisconnect(id, connectionHandle) {
|
|
322
420
|
const conn = this.#connections.get(connectionHandle);
|
|
323
421
|
if (!conn) {
|
|
324
|
-
this.#sendError(id,
|
|
422
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${connectionHandle}`);
|
|
325
423
|
return;
|
|
326
424
|
}
|
|
327
425
|
if (conn.peripheral.state === "connected") {
|
|
@@ -333,7 +431,7 @@ class NobleBleProxyClient {
|
|
|
333
431
|
async #handleDiscoverServices(id, args) {
|
|
334
432
|
const conn = this.#connections.get(args.connection_handle);
|
|
335
433
|
if (!conn) {
|
|
336
|
-
this.#sendError(id,
|
|
434
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
|
|
337
435
|
return;
|
|
338
436
|
}
|
|
339
437
|
if (conn.services.size > 0) {
|
|
@@ -343,11 +441,17 @@ class NobleBleProxyClient {
|
|
|
343
441
|
return;
|
|
344
442
|
}
|
|
345
443
|
log(`[GATT] handle=${args.connection_handle} discovering services (lazy)...`);
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
444
|
+
let services;
|
|
445
|
+
try {
|
|
446
|
+
services = await withTimeout(
|
|
447
|
+
conn.peripheral.discoverServicesAsync([]),
|
|
448
|
+
1e4,
|
|
449
|
+
"discoverServices timed out after 10s"
|
|
450
|
+
);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
this.#sendError(id, BleProxyErrorCode.DiscoveryFailed, err.message);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
351
455
|
for (const service of services) {
|
|
352
456
|
conn.services.set(service.uuid, service);
|
|
353
457
|
}
|
|
@@ -359,12 +463,12 @@ class NobleBleProxyClient {
|
|
|
359
463
|
async #handleDiscoverCharacteristics(id, args) {
|
|
360
464
|
const conn = this.#connections.get(args.connection_handle);
|
|
361
465
|
if (!conn) {
|
|
362
|
-
this.#sendError(id,
|
|
466
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
|
|
363
467
|
return;
|
|
364
468
|
}
|
|
365
469
|
const service = conn.services.get(args.service_uuid);
|
|
366
470
|
if (!service) {
|
|
367
|
-
this.#sendError(id,
|
|
471
|
+
this.#sendError(id, BleProxyErrorCode.ServiceNotFound, `Service ${args.service_uuid} not found`);
|
|
368
472
|
return;
|
|
369
473
|
}
|
|
370
474
|
const cachedChars = service.characteristics ?? [];
|
|
@@ -381,11 +485,17 @@ class NobleBleProxyClient {
|
|
|
381
485
|
return;
|
|
382
486
|
}
|
|
383
487
|
log(`[GATT] handle=${args.connection_handle} discovering characteristics for ${args.service_uuid} (lazy)...`);
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
488
|
+
let characteristics;
|
|
489
|
+
try {
|
|
490
|
+
characteristics = await withTimeout(
|
|
491
|
+
service.discoverCharacteristicsAsync([]),
|
|
492
|
+
1e4,
|
|
493
|
+
`discoverCharacteristics(${args.service_uuid}) timed out after 10s`
|
|
494
|
+
);
|
|
495
|
+
} catch (err) {
|
|
496
|
+
this.#sendError(id, BleProxyErrorCode.DiscoveryFailed, err.message);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
389
499
|
for (const char of characteristics) {
|
|
390
500
|
conn.characteristics.set(char.uuid, char);
|
|
391
501
|
}
|
|
@@ -402,78 +512,167 @@ class NobleBleProxyClient {
|
|
|
402
512
|
async #handleReadCharacteristic(id, args) {
|
|
403
513
|
const conn = this.#connections.get(args.connection_handle);
|
|
404
514
|
if (!conn) {
|
|
405
|
-
this.#sendError(id,
|
|
515
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
|
|
406
516
|
return;
|
|
407
517
|
}
|
|
408
518
|
const char = this.#findCharacteristic(conn, args.characteristic_uuid);
|
|
409
519
|
if (!char) {
|
|
410
|
-
this.#sendError(
|
|
520
|
+
this.#sendError(
|
|
521
|
+
id,
|
|
522
|
+
BleProxyErrorCode.CharacteristicNotFound,
|
|
523
|
+
`Characteristic ${args.characteristic_uuid} not found`
|
|
524
|
+
);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
let data;
|
|
528
|
+
try {
|
|
529
|
+
data = await char.readAsync();
|
|
530
|
+
} catch (err) {
|
|
531
|
+
this.#sendError(
|
|
532
|
+
id,
|
|
533
|
+
BleProxyErrorCode.ReadFailed,
|
|
534
|
+
`read(${args.characteristic_uuid}): ${err.message}`
|
|
535
|
+
);
|
|
411
536
|
return;
|
|
412
537
|
}
|
|
413
|
-
const data = await char.readAsync();
|
|
414
538
|
log(`[GATT] read ${args.characteristic_uuid} \u2192 ${data.length} bytes`);
|
|
415
539
|
this.#sendSuccess(id, { value: Buffer.from(data).toString("base64") });
|
|
416
540
|
}
|
|
417
541
|
async #handleWriteCharacteristic(id, args) {
|
|
418
542
|
const conn = this.#connections.get(args.connection_handle);
|
|
419
543
|
if (!conn) {
|
|
420
|
-
this.#sendError(id,
|
|
544
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
|
|
421
545
|
return;
|
|
422
546
|
}
|
|
423
547
|
const char = this.#findCharacteristic(conn, args.characteristic_uuid);
|
|
424
548
|
if (!char) {
|
|
425
|
-
this.#sendError(
|
|
549
|
+
this.#sendError(
|
|
550
|
+
id,
|
|
551
|
+
BleProxyErrorCode.CharacteristicNotFound,
|
|
552
|
+
`Characteristic ${args.characteristic_uuid} not found`
|
|
553
|
+
);
|
|
426
554
|
return;
|
|
427
555
|
}
|
|
428
556
|
const data = Buffer.from(args.value, "base64");
|
|
429
557
|
const withResponse = args.response ?? false;
|
|
430
558
|
log(`[GATT] write ${args.characteristic_uuid} ${data.length} bytes withResponse=${withResponse}`);
|
|
431
|
-
|
|
559
|
+
try {
|
|
560
|
+
await char.writeAsync(data, !withResponse);
|
|
561
|
+
} catch (err) {
|
|
562
|
+
this.#sendError(
|
|
563
|
+
id,
|
|
564
|
+
BleProxyErrorCode.WriteFailed,
|
|
565
|
+
`write(${args.characteristic_uuid}): ${err.message}`
|
|
566
|
+
);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
432
569
|
conn.lastWriteCharacteristic = char;
|
|
433
570
|
this.#sendSuccess(id);
|
|
434
571
|
}
|
|
435
572
|
async #handleSubscribeCharacteristic(id, args) {
|
|
436
573
|
const conn = this.#connections.get(args.connection_handle);
|
|
437
574
|
if (!conn) {
|
|
438
|
-
this.#sendError(id,
|
|
575
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
|
|
439
576
|
return;
|
|
440
577
|
}
|
|
441
578
|
const char = this.#findCharacteristic(conn, args.characteristic_uuid);
|
|
442
579
|
if (!char) {
|
|
443
|
-
this.#sendError(
|
|
580
|
+
this.#sendError(
|
|
581
|
+
id,
|
|
582
|
+
BleProxyErrorCode.CharacteristicNotFound,
|
|
583
|
+
`Characteristic ${args.characteristic_uuid} not found`
|
|
584
|
+
);
|
|
444
585
|
return;
|
|
445
586
|
}
|
|
446
587
|
const handle = args.connection_handle;
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
});
|
|
451
|
-
await char.subscribeAsync();
|
|
452
|
-
conn.subscriptions.set(args.characteristic_uuid, char);
|
|
588
|
+
if (!await this.#subscribe(id, conn, handle, args.characteristic_uuid, char)) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
453
591
|
log(`[GATT] subscribe ${args.characteristic_uuid} handle=${handle}`);
|
|
454
592
|
this.#sendSuccess(id);
|
|
455
593
|
}
|
|
594
|
+
/** No-op when `uuid` is already subscribed. Returns false once an error response was sent. */
|
|
595
|
+
async #subscribe(id, conn, handle, uuid, char) {
|
|
596
|
+
const key = NobleBleProxyClient.#subscriptionKey(uuid);
|
|
597
|
+
if (conn.subscriptions.has(key)) {
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
const onData = this.#notificationForwarder(handle, uuid);
|
|
601
|
+
char.on("data", onData);
|
|
602
|
+
try {
|
|
603
|
+
await char.subscribeAsync();
|
|
604
|
+
} catch (err) {
|
|
605
|
+
char.removeListener("data", onData);
|
|
606
|
+
this.#sendError(id, BleProxyErrorCode.SubscribeFailed, `subscribe(${uuid}): ${err.message}`);
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
conn.subscriptions.set(key, { characteristic: char, onData });
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
async #handleWriteAndSubscribe(id, args) {
|
|
613
|
+
const conn = this.#connections.get(args.connection_handle);
|
|
614
|
+
if (!conn) {
|
|
615
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const writeChar = this.#findCharacteristic(conn, args.write_uuid);
|
|
619
|
+
if (!writeChar) {
|
|
620
|
+
this.#sendError(
|
|
621
|
+
id,
|
|
622
|
+
BleProxyErrorCode.CharacteristicNotFound,
|
|
623
|
+
`Characteristic ${args.write_uuid} not found`
|
|
624
|
+
);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const subscribeChar = this.#findCharacteristic(conn, args.subscribe_uuid);
|
|
628
|
+
if (!subscribeChar) {
|
|
629
|
+
this.#sendError(
|
|
630
|
+
id,
|
|
631
|
+
BleProxyErrorCode.CharacteristicNotFound,
|
|
632
|
+
`Characteristic ${args.subscribe_uuid} not found`
|
|
633
|
+
);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const handle = args.connection_handle;
|
|
637
|
+
const value = Buffer.from(args.write_value, "base64");
|
|
638
|
+
const withResponse = args.write_response ?? false;
|
|
639
|
+
log(
|
|
640
|
+
`[GATT] write_and_subscribe handle=${handle} write=${args.write_uuid} ${value.length} bytes withResponse=${withResponse} subscribe=${args.subscribe_uuid}`
|
|
641
|
+
);
|
|
642
|
+
try {
|
|
643
|
+
await writeChar.writeAsync(value, !withResponse);
|
|
644
|
+
} catch (err) {
|
|
645
|
+
this.#sendError(id, BleProxyErrorCode.WriteFailed, `write(${args.write_uuid}): ${err.message}`);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
conn.lastWriteCharacteristic = writeChar;
|
|
649
|
+
if (!await this.#subscribe(id, conn, handle, args.subscribe_uuid, subscribeChar)) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
this.#sendSuccess(id);
|
|
653
|
+
}
|
|
456
654
|
async #handleUnsubscribeCharacteristic(id, args) {
|
|
457
655
|
const conn = this.#connections.get(args.connection_handle);
|
|
458
656
|
if (!conn) {
|
|
459
|
-
this.#sendError(id,
|
|
657
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
|
|
460
658
|
return;
|
|
461
659
|
}
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
660
|
+
const key = NobleBleProxyClient.#subscriptionKey(args.characteristic_uuid);
|
|
661
|
+
const subscription = conn.subscriptions.get(key);
|
|
662
|
+
if (!subscription) {
|
|
663
|
+
this.#sendError(id, BleProxyErrorCode.NotSubscribed, `Not subscribed to ${args.characteristic_uuid}`);
|
|
465
664
|
return;
|
|
466
665
|
}
|
|
467
|
-
await
|
|
468
|
-
|
|
469
|
-
conn.subscriptions.delete(
|
|
666
|
+
await subscription.characteristic.unsubscribeAsync();
|
|
667
|
+
subscription.characteristic.removeListener("data", subscription.onData);
|
|
668
|
+
conn.subscriptions.delete(key);
|
|
470
669
|
log(`[GATT] unsubscribe ${args.characteristic_uuid} handle=${args.connection_handle}`);
|
|
471
670
|
this.#sendSuccess(id);
|
|
472
671
|
}
|
|
473
672
|
async #handleRequestMtu(id, connectionHandle, mtu) {
|
|
474
673
|
const conn = this.#connections.get(connectionHandle);
|
|
475
674
|
if (!conn) {
|
|
476
|
-
this.#sendError(id,
|
|
675
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${connectionHandle}`);
|
|
477
676
|
return;
|
|
478
677
|
}
|
|
479
678
|
const actualMtu = conn.peripheral.mtu ?? mtu;
|
|
@@ -482,7 +681,13 @@ class NobleBleProxyClient {
|
|
|
482
681
|
}
|
|
483
682
|
// ─── Binary Frame Handling ───────────────────────────────────────────────
|
|
484
683
|
#handleBinaryFrame(data) {
|
|
485
|
-
|
|
684
|
+
let frame;
|
|
685
|
+
try {
|
|
686
|
+
frame = decodeBinaryFrame(new Uint8Array(data));
|
|
687
|
+
} catch (err) {
|
|
688
|
+
warn(`[\u2190BIN] failed to decode binary frame: ${err.message}`);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
486
691
|
log(`[\u2190BIN] opcode=${frame.opcode} handle=${frame.connectionHandle} payload=${frame.payload.length} bytes`);
|
|
487
692
|
if (frame.opcode === BinaryFrameOpcode.WriteData) {
|
|
488
693
|
const conn = this.#connections.get(frame.connectionHandle);
|
|
@@ -496,6 +701,88 @@ class NobleBleProxyClient {
|
|
|
496
701
|
}
|
|
497
702
|
}
|
|
498
703
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
704
|
+
/** Records the scan the server wants, then drives noble to it. Resolves once that settled. */
|
|
705
|
+
#requestScan(config) {
|
|
706
|
+
this.#desiredScan = config;
|
|
707
|
+
return this.#reconcileScan(++this.#scanGeneration);
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Suspends the running scan without forgetting it, so a resume restores the server's filter.
|
|
711
|
+
* Counted, not a flag: `connect` commands overlap, and the first to finish must not resume a
|
|
712
|
+
* scan while another is still interviewing — on macOS that reinstates the discovery hang the
|
|
713
|
+
* pause exists to prevent. Returns the release, which is safe to call more than once.
|
|
714
|
+
*/
|
|
715
|
+
async #acquireScanPause() {
|
|
716
|
+
this.#scanPauses++;
|
|
717
|
+
let released = false;
|
|
718
|
+
const release = async () => {
|
|
719
|
+
if (released) {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
released = true;
|
|
723
|
+
this.#scanPauses--;
|
|
724
|
+
await this.#reconcileScan(++this.#scanGeneration);
|
|
725
|
+
};
|
|
726
|
+
try {
|
|
727
|
+
await this.#reconcileScan(++this.#scanGeneration);
|
|
728
|
+
} catch (err) {
|
|
729
|
+
await release();
|
|
730
|
+
throw err;
|
|
731
|
+
}
|
|
732
|
+
return release;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Drives noble to the state `generation` asked for, or returns once a later request has taken
|
|
736
|
+
* ownership of the outcome. noble registers each scan call's completion callback with
|
|
737
|
+
* `onceExclusive`, which removes the previously registered one, so two overlapping calls leave
|
|
738
|
+
* the earlier promise unsettled forever — every scan call is therefore serialized here.
|
|
739
|
+
*/
|
|
740
|
+
#reconcileScan(generation) {
|
|
741
|
+
return this.#serializeScanOperation(async () => {
|
|
742
|
+
if (generation !== this.#scanGeneration) {
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const target = this.#scanPauses > 0 ? void 0 : this.#desiredScan;
|
|
746
|
+
await this.#callNoble((noble) => noble.stopScanningAsync(), "stopScanning");
|
|
747
|
+
if (target) {
|
|
748
|
+
await this.#callNoble((noble) => noble.startScanningAsync(target.serviceUuids, true), "startScanning");
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
#serializeScanOperation(operation) {
|
|
753
|
+
const result = this.#scanOperations.then(operation);
|
|
754
|
+
this.#scanOperations = result.catch(() => void 0);
|
|
755
|
+
return result;
|
|
756
|
+
}
|
|
757
|
+
async #callNoble(call, description) {
|
|
758
|
+
const noble = this.#noble;
|
|
759
|
+
if (!noble) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const pending = call(noble);
|
|
763
|
+
pending.catch(() => void 0);
|
|
764
|
+
await withTimeout(
|
|
765
|
+
pending,
|
|
766
|
+
this.#scanOperationTimeoutMs,
|
|
767
|
+
`${description} did not settle within ${this.#scanOperationTimeoutMs}ms`
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
// Forwarded regardless of noble's `isNotification`: on macOS that flag is derived from a
|
|
771
|
+
// manager-wide pendingRead, so it reads false for a notification racing a read on any
|
|
772
|
+
// peripheral. Safe only while no subscribed characteristic is ever read.
|
|
773
|
+
#notificationForwarder(handle, uuid) {
|
|
774
|
+
return (payload) => {
|
|
775
|
+
if (payload === null) {
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
log(`[GATT] notify ${uuid} handle=${handle} ${payload.length} bytes`);
|
|
779
|
+
this.#sendBinaryFrame(BinaryFrameOpcode.Notification, handle, new Uint8Array(payload));
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
/** Subscription key; `#findCharacteristic` resolves spellings that differ only in case or dashes. */
|
|
783
|
+
static #subscriptionKey(uuid) {
|
|
784
|
+
return uuid.replace(/-/g, "").toLowerCase();
|
|
785
|
+
}
|
|
499
786
|
#findCharacteristic(conn, uuid) {
|
|
500
787
|
return conn.characteristics.get(uuid) ?? conn.characteristics.get(uuid.toLowerCase()) ?? conn.characteristics.get(uuid.toUpperCase().replace(/-/g, "").toLowerCase());
|
|
501
788
|
}
|