@matter-server/ble-proxy 1.4.0 → 1.4.1-alpha.0-20261008-d619c2e

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.
@@ -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
- const msg = JSON.parse(data.toString());
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, _args) => this.#handleStartScan(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, "internal_error", `Unknown command: ${msg.command}`);
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, "internal_error", `${err.message}`);
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, "bluetooth_unavailable", "Noble not initialized");
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.#noble.removeAllListeners("discover");
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
- await this.#noble?.stopScanningAsync();
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.#discoveredPeripherals.get(args.address);
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, "device_not_found", `No device found for address ${args.address}`);
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.#noble.stopScanningAsync();
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.#noble.startScanningAsync(["fff6"], true).catch(
316
- (scanErr) => warn(`[SCAN] failed to resume scanning after connect failure: ${scanErr.message}`)
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, "not_connected", `No connection with handle ${connectionHandle}`);
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, "not_connected", `No connection with handle ${args.connection_handle}`);
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
- const services = await withTimeout(
347
- conn.peripheral.discoverServicesAsync([]),
348
- 1e4,
349
- "discoverServices timed out after 10s"
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, "not_connected", `No connection with handle ${args.connection_handle}`);
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, "service_not_found", `Service ${args.service_uuid} not found`);
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
- const characteristics = await withTimeout(
385
- service.discoverCharacteristicsAsync([]),
386
- 1e4,
387
- `discoverCharacteristics(${args.service_uuid}) timed out after 10s`
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, "not_connected", `No connection with handle ${args.connection_handle}`);
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(id, "characteristic_not_found", `Characteristic ${args.characteristic_uuid} not found`);
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, "not_connected", `No connection with handle ${args.connection_handle}`);
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(id, "characteristic_not_found", `Characteristic ${args.characteristic_uuid} not found`);
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
- await char.writeAsync(data, !withResponse);
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, "not_connected", `No connection with handle ${args.connection_handle}`);
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(id, "characteristic_not_found", `Characteristic ${args.characteristic_uuid} not found`);
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
- char.on("data", (data) => {
448
- log(`[GATT] notify ${args.characteristic_uuid} handle=${handle} ${data.length} bytes`);
449
- this.#sendBinaryFrame(BinaryFrameOpcode.Notification, handle, new Uint8Array(data));
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, "not_connected", `No connection with handle ${args.connection_handle}`);
657
+ this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
460
658
  return;
461
659
  }
462
- const char = conn.subscriptions.get(args.characteristic_uuid);
463
- if (!char) {
464
- this.#sendError(id, "not_subscribed", `Not subscribed to ${args.characteristic_uuid}`);
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 char.unsubscribeAsync();
468
- char.removeAllListeners("data");
469
- conn.subscriptions.delete(args.characteristic_uuid);
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, "not_connected", `No connection with handle ${connectionHandle}`);
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
- const frame = decodeBinaryFrame(new Uint8Array(data));
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
  }