@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.
@@ -19,19 +19,32 @@ import {
19
19
  BLE_PROXY_PROTOCOL_VERSION,
20
20
  BinaryFrameOpcode,
21
21
  type BleProxyCommandName,
22
+ BleProxyErrorCode,
23
+ type BleProxyErrorCodeValue,
22
24
  type CommandMessage,
23
25
  type ConnectArgs,
24
26
  type DeviceDiscoveredData,
25
27
  type DiscoverCharacteristicsArgs,
26
28
  type DiscoverServicesArgs,
27
29
  type ReadCharacteristicArgs,
30
+ type StartScanArgs,
28
31
  type SubscribeCharacteristicArgs,
29
32
  type UnsubscribeCharacteristicArgs,
33
+ type WriteAndSubscribeArgs,
30
34
  type WriteCharacteristicArgs,
31
35
  decodeBinaryFrame,
32
36
  encodeBinaryFrame,
33
37
  } from "../BleProxyProtocol.js";
34
38
 
39
+ /**
40
+ * A `connect` can arrive before this process has advertised the address it names — observed at
41
+ * 104 ms ahead of the client's own `device_discovered`.
42
+ */
43
+ const CONNECT_DISCOVERY_TIMEOUT_MS = 5_000;
44
+
45
+ /** Bounds one noble scan call, so a wedged adapter cannot stall every later scan and connect. */
46
+ const SCAN_OPERATION_TIMEOUT_MS = 10_000;
47
+
35
48
  function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
36
49
  return Promise.race([promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(message)), ms))]);
37
50
  }
@@ -57,15 +70,90 @@ function error(...args: unknown[]): void {
57
70
  console.error(...args);
58
71
  }
59
72
 
60
- type Peripheral = import("@stoprocent/noble").Peripheral;
61
- type Characteristic = import("@stoprocent/noble").Characteristic;
62
- type Service = import("@stoprocent/noble").Service;
73
+ /**
74
+ * The part of noble this client drives. Structural so a test can supply a stand-in without
75
+ * implementing the full `Noble` surface; the real instance satisfies it.
76
+ */
77
+ export interface NobleApi {
78
+ startScanningAsync(serviceUuids: string[], allowDuplicates: boolean): Promise<void>;
79
+ stopScanningAsync(): Promise<void>;
80
+ stop(): void;
81
+ on(event: "warning" | "stateChange", listener: (value: string) => void): unknown;
82
+ on(event: "discover", listener: (peripheral: Peripheral) => void): unknown;
83
+ removeAllListeners(event: "discover"): unknown;
84
+ }
85
+
86
+ /**
87
+ * The parts of noble's GATT objects this client drives. Structural for the same reason as
88
+ * `NobleApi`: a test can supply stand-ins, and noble's own types satisfy these on assignment.
89
+ */
90
+ export interface Characteristic {
91
+ readonly uuid: string;
92
+ readonly properties: string[];
93
+ readAsync(): Promise<Buffer>;
94
+ writeAsync(data: Buffer, withoutResponse: boolean): Promise<void>;
95
+ subscribeAsync(): Promise<void>;
96
+ unsubscribeAsync(): Promise<void>;
97
+ on(event: "data", listener: NotificationListener): unknown;
98
+ removeListener(event: "data", listener: NotificationListener): unknown;
99
+ }
100
+
101
+ export interface Service {
102
+ readonly uuid: string;
103
+ readonly characteristics?: Characteristic[];
104
+ discoverCharacteristicsAsync(uuids?: string[]): Promise<Characteristic[]>;
105
+ }
106
+
107
+ export interface Peripheral {
108
+ readonly id: string;
109
+ readonly address: string;
110
+ readonly rssi: number;
111
+ readonly mtu?: number | null;
112
+ readonly state: string;
113
+ readonly connectable?: boolean;
114
+ readonly advertisement: {
115
+ localName?: string;
116
+ serviceUuids?: string[];
117
+ serviceData?: Array<{ uuid: string; data: Uint8Array }>;
118
+ };
119
+ connectAsync(): Promise<void>;
120
+ disconnectAsync(): Promise<void>;
121
+ discoverServicesAsync(serviceUuids: string[]): Promise<Service[]>;
122
+ once(event: "disconnect", listener: () => void): unknown;
123
+ removeListener(event: "disconnect", listener: () => void): unknown;
124
+ }
125
+
126
+ /** noble reuses the "data" event for read responses, and delivers a null payload on read failure. */
127
+ export type NotificationListener = (payload: Buffer | null) => void;
128
+
129
+ interface Subscription {
130
+ characteristic: Characteristic;
131
+ onData: NotificationListener;
132
+ }
133
+
134
+ /** The scan the server has asked for; `undefined` means it has asked for none. */
135
+ interface ScanConfig {
136
+ serviceUuids: string[];
137
+ reportDuplicates: boolean;
138
+ }
139
+
140
+ export interface NobleBleProxyClientOptions {
141
+ /** Stand-in for the dynamically imported noble instance. */
142
+ noble?: NobleApi;
143
+ scanOperationTimeoutMs?: number;
144
+ }
145
+
146
+ /** A pending `#awaitDiscovered`, kept so shutdown can settle it and clear its timer. */
147
+ interface DiscoveryWaiter {
148
+ resolve: (peripheral: Peripheral | undefined) => void;
149
+ timer: ReturnType<typeof setTimeout>;
150
+ }
63
151
 
64
152
  interface ConnectionState {
65
153
  peripheral: Peripheral;
66
154
  services: Map<string, Service>;
67
155
  characteristics: Map<string, Characteristic>;
68
- subscriptions: Map<string, Characteristic>;
156
+ subscriptions: Map<string, Subscription>;
69
157
  lastWriteCharacteristic?: Characteristic;
70
158
  }
71
159
 
@@ -83,20 +171,34 @@ export class NobleBleProxyClient {
83
171
  readonly #serverUrl: string;
84
172
  readonly #hciId?: number;
85
173
  #ws?: WebSocket;
86
- #noble?: import("@stoprocent/noble").Noble;
174
+ #noble?: NobleApi;
87
175
  #connections = new Map<number, ConnectionState>();
88
176
  #nextHandle = 1;
89
177
  #discoveredPeripherals = new Map<string, Peripheral>();
178
+ #discoveryWaiters = new Map<string, Set<DiscoveryWaiter>>();
90
179
  #lastDiscoverFingerprint = new Map<string, DiscoverFingerprint>();
180
+ #desiredScan?: ScanConfig;
181
+ #scanPauses = 0;
182
+ #scanGeneration = 0;
183
+ #scanOperations: Promise<unknown> = Promise.resolve();
184
+ #discoverListenerInstalled = false;
185
+ readonly #scanOperationTimeoutMs: number;
91
186
  #commandHandlers = new Map<BleProxyCommandName, CommandHandler>();
92
187
  #closing = false;
93
188
 
94
- constructor(serverUrl: string, hciId?: number) {
189
+ constructor(serverUrl: string, hciId?: number, options: NobleBleProxyClientOptions = {}) {
95
190
  this.#serverUrl = serverUrl;
96
191
  this.#hciId = hciId;
192
+ this.#noble = options.noble;
193
+ this.#scanOperationTimeoutMs = options.scanOperationTimeoutMs ?? SCAN_OPERATION_TIMEOUT_MS;
97
194
  this.#registerCommandHandlers();
98
195
  }
99
196
 
197
+ /** Command names this client implements. Must cover every `BleProxyCommand` value. */
198
+ get supportedCommands(): BleProxyCommandName[] {
199
+ return [...this.#commandHandlers.keys()];
200
+ }
201
+
100
202
  async connect(): Promise<void> {
101
203
  // Load Noble dynamically (it's an optional dependency)
102
204
  await this.#loadNoble();
@@ -118,7 +220,19 @@ export class NobleBleProxyClient {
118
220
  return;
119
221
  }
120
222
 
121
- const msg = JSON.parse(data.toString());
223
+ // A throw here escapes into ws's receiver and takes the process down with it.
224
+ let msg;
225
+ try {
226
+ msg = JSON.parse(data.toString());
227
+ } catch {
228
+ warn("Received invalid JSON from the server");
229
+ return;
230
+ }
231
+ // Valid JSON is not necessarily an object, and `"id" in null` throws.
232
+ if (typeof msg !== "object" || msg === null) {
233
+ warn("Ignoring non-object JSON message from the server");
234
+ return;
235
+ }
122
236
 
123
237
  if (!handshakeComplete) {
124
238
  if (msg.type === "hello_response") {
@@ -182,11 +296,21 @@ export class NobleBleProxyClient {
182
296
  }
183
297
  }
184
298
  this.#connections.clear();
299
+ for (const waiters of this.#discoveryWaiters.values()) {
300
+ for (const waiter of waiters) {
301
+ clearTimeout(waiter.timer);
302
+ waiter.resolve(undefined);
303
+ }
304
+ }
305
+ this.#discoveryWaiters.clear();
185
306
  this.#noble?.stop();
186
307
  this.#ws?.close();
187
308
  }
188
309
 
189
310
  async #loadNoble(): Promise<void> {
311
+ if (this.#noble) {
312
+ return;
313
+ }
190
314
  if (this.#hciId !== undefined) {
191
315
  process.env.NOBLE_HCI_DEVICE_ID = this.#hciId.toString();
192
316
  }
@@ -208,7 +332,7 @@ export class NobleBleProxyClient {
208
332
  }
209
333
 
210
334
  #registerCommandHandlers(): void {
211
- this.#commandHandlers.set("start_scan", (id, _args) => this.#handleStartScan(id));
335
+ this.#commandHandlers.set("start_scan", (id, args) => this.#handleStartScan(id, args));
212
336
  this.#commandHandlers.set("stop_scan", id => this.#handleStopScan(id));
213
337
  this.#commandHandlers.set("connect", (id, args) => this.#handleConnect(id, args as unknown as ConnectArgs));
214
338
  this.#commandHandlers.set("disconnect", (id, args) =>
@@ -229,6 +353,9 @@ export class NobleBleProxyClient {
229
353
  this.#commandHandlers.set("subscribe_characteristic", (id, args) =>
230
354
  this.#handleSubscribeCharacteristic(id, args as unknown as SubscribeCharacteristicArgs),
231
355
  );
356
+ this.#commandHandlers.set("write_and_subscribe", (id, args) =>
357
+ this.#handleWriteAndSubscribe(id, args as unknown as WriteAndSubscribeArgs),
358
+ );
232
359
  this.#commandHandlers.set("unsubscribe_characteristic", (id, args) =>
233
360
  this.#handleUnsubscribeCharacteristic(id, args as unknown as UnsubscribeCharacteristicArgs),
234
361
  );
@@ -247,33 +374,65 @@ export class NobleBleProxyClient {
247
374
  const handler = this.#commandHandlers.get(msg.command);
248
375
  if (!handler) {
249
376
  error(`[→ERR] id=${msg.id} Unknown command: ${msg.command}`);
250
- this.#sendError(msg.id, "internal_error", `Unknown command: ${msg.command}`);
377
+ this.#sendError(msg.id, BleProxyErrorCode.InternalError, `Unknown command: ${msg.command}`);
251
378
  return;
252
379
  }
253
380
  try {
254
381
  await handler(msg.id, msg.args ?? {});
255
382
  } catch (err) {
256
383
  error(`[→ERR] id=${msg.id} ${msg.command} threw: ${(err as Error).message}`);
257
- this.#sendError(msg.id, "internal_error", `${(err as Error).message}`);
384
+ this.#sendError(msg.id, BleProxyErrorCode.InternalError, `${(err as Error).message}`);
258
385
  }
259
386
  }
260
387
 
261
388
  // ─── Command Handlers ────────────────────────────────────────────────────
262
389
 
263
- async #handleStartScan(id: number): Promise<void> {
390
+ async #handleStartScan(id: number, args: StartScanArgs): Promise<void> {
264
391
  if (!this.#noble) {
265
- this.#sendError(id, "bluetooth_unavailable", "Noble not initialized");
392
+ this.#sendError(id, BleProxyErrorCode.BluetoothUnavailable, "Noble not initialized");
266
393
  return;
267
394
  }
268
395
 
396
+ const config: ScanConfig = {
397
+ serviceUuids: args.service_uuids ?? [],
398
+ reportDuplicates: args.allow_duplicates ?? true,
399
+ };
269
400
  this.#lastDiscoverFingerprint.clear();
270
- // Remove any existing discover listeners to prevent duplicates on repeated scans
271
- this.#noble.removeAllListeners("discover");
272
- this.#noble.on("discover", (peripheral: Peripheral) => {
401
+ this.#installDiscoverListener();
402
+
403
+ try {
404
+ await this.#requestScan(config);
405
+ } catch (err) {
406
+ this.#sendError(id, BleProxyErrorCode.InternalError, (err as Error).message);
407
+ return;
408
+ }
409
+ log(
410
+ `[SCAN] BLE scan started (filter: ${config.serviceUuids.join(",") || "none"}` +
411
+ ` allowDuplicates=${config.reportDuplicates})`,
412
+ );
413
+ this.#sendSuccess(id);
414
+ }
415
+
416
+ /** Installed once and kept: noble emits `discover` only while a scan is running. */
417
+ #installDiscoverListener(): void {
418
+ if (this.#discoverListenerInstalled) {
419
+ return;
420
+ }
421
+ this.#discoverListenerInstalled = true;
422
+ this.#noble!.on("discover", (peripheral: Peripheral) => {
273
423
  // On macOS, peripheral.address is often empty — fall back to peripheral.id (UUID)
274
424
  const address = peripheral.address || peripheral.id;
275
425
  this.#discoveredPeripherals.set(address, peripheral);
276
426
 
427
+ const waiters = this.#discoveryWaiters.get(address);
428
+ if (waiters) {
429
+ this.#discoveryWaiters.delete(address);
430
+ for (const waiter of waiters) {
431
+ clearTimeout(waiter.timer);
432
+ waiter.resolve(peripheral);
433
+ }
434
+ }
435
+
277
436
  const serviceData: Record<string, string> = {};
278
437
  for (const sd of peripheral.advertisement.serviceData ?? []) {
279
438
  serviceData[sd.uuid] = Buffer.from(sd.data).toString("base64");
@@ -301,7 +460,7 @@ export class NobleBleProxyClient {
301
460
  prev.serviceUuids !== fingerprint.serviceUuids ||
302
461
  prev.serviceData !== fingerprint.serviceData;
303
462
 
304
- if (!changed) {
463
+ if (!(this.#desiredScan?.reportDuplicates ?? true) && !changed) {
305
464
  return;
306
465
  }
307
466
  this.#lastDiscoverFingerprint.set(address, fingerprint);
@@ -323,25 +482,57 @@ export class NobleBleProxyClient {
323
482
 
324
483
  this.#sendEvent("device_discovered", event as unknown as Record<string, unknown>);
325
484
  });
326
-
327
- await this.#noble.startScanningAsync(["fff6"], true);
328
- log("[SCAN] BLE scan started (filter: fff6)");
329
- this.#sendSuccess(id);
330
485
  }
331
486
 
332
487
  async #handleStopScan(id: number): Promise<void> {
333
- await this.#noble?.stopScanningAsync();
488
+ try {
489
+ await this.#requestScan(undefined);
490
+ } catch (err) {
491
+ this.#sendError(id, BleProxyErrorCode.InternalError, (err as Error).message);
492
+ return;
493
+ }
334
494
  log("[SCAN] BLE scan stopped");
335
495
  this.#sendSuccess(id);
336
496
  }
337
497
 
498
+ /** Resolves once `address` is advertised, or undefined once the wait times out. */
499
+ #awaitDiscovered(address: string): Promise<Peripheral | undefined> {
500
+ const known = this.#discoveredPeripherals.get(address);
501
+ if (known) {
502
+ return Promise.resolve(known);
503
+ }
504
+
505
+ if (!this.#desiredScan) {
506
+ return Promise.resolve(undefined);
507
+ }
508
+
509
+ log(`[CONN] "${address}" not advertised yet, waiting up to ${CONNECT_DISCOVERY_TIMEOUT_MS}ms`);
510
+ return new Promise<Peripheral | undefined>(resolve => {
511
+ const waiters = this.#discoveryWaiters.get(address) ?? new Set();
512
+ this.#discoveryWaiters.set(address, waiters);
513
+
514
+ const waiter: DiscoveryWaiter = {
515
+ resolve,
516
+ timer: setTimeout(() => {
517
+ waiters.delete(waiter);
518
+ if (waiters.size === 0) {
519
+ this.#discoveryWaiters.delete(address);
520
+ }
521
+ resolve(undefined);
522
+ }, CONNECT_DISCOVERY_TIMEOUT_MS),
523
+ };
524
+ waiters.add(waiter);
525
+ });
526
+ }
527
+
338
528
  async #handleConnect(id: number, args: ConnectArgs): Promise<void> {
339
- const peripheral = this.#discoveredPeripherals.get(args.address);
529
+ const peripheral = await this.#awaitDiscovered(args.address);
340
530
  if (!peripheral) {
341
531
  error(
342
- `[CONN] No peripheral found for address "${args.address}". Known: ${[...this.#discoveredPeripherals.keys()].join(", ")}`,
532
+ `[CONN] No peripheral found for address "${args.address}" after ${CONNECT_DISCOVERY_TIMEOUT_MS}ms.` +
533
+ ` Known: ${[...this.#discoveredPeripherals.keys()].join(", ")}`,
343
534
  );
344
- this.#sendError(id, "device_not_found", `No device found for address ${args.address}`);
535
+ this.#sendError(id, BleProxyErrorCode.DeviceNotFound, `No device found for address ${args.address}`);
345
536
  return;
346
537
  }
347
538
 
@@ -366,12 +557,13 @@ export class NobleBleProxyClient {
366
557
  peripheral.once("disconnect", disconnectListener);
367
558
 
368
559
  log(`[CONN] Connecting to "${args.address}" (state=${peripheral.state})...`);
560
+ let releaseScanPause: (() => Promise<void>) | undefined;
369
561
  try {
370
562
  // Pause scanning during connect + GATT discovery. On macOS, scanning concurrently
371
563
  // with `service.discoverCharacteristicsAsync` causes the CoreBluetooth delegate
372
564
  // callback to never fire; the peripheral stays connected but discovery hangs.
373
565
  log(`[SCAN] pausing scan for connect+interview...`);
374
- await this.#noble!.stopScanningAsync();
566
+ releaseScanPause = await this.#acquireScanPause();
375
567
 
376
568
  await peripheral.connectAsync();
377
569
  log(`[CONN] Connected handle=${handle} state=${peripheral.state} mtu=${peripheral.mtu ?? "?"}`);
@@ -404,10 +596,6 @@ export class NobleBleProxyClient {
404
596
  const mtu = peripheral.mtu ?? 23;
405
597
  log(`[GATT] handle=${handle} ready mtu=${mtu}`);
406
598
 
407
- // Resume scanning so the server can still observe new devices and rssi updates.
408
- log(`[SCAN] resuming scan after connect+interview...`);
409
- await this.#noble!.startScanningAsync(["fff6"], true);
410
-
411
599
  this.#sendSuccess(id, { connection_handle: handle, mtu });
412
600
  } catch (err) {
413
601
  const reason = disconnectedReason ?? (err as Error).message;
@@ -423,20 +611,19 @@ export class NobleBleProxyClient {
423
611
  warn(`[CONN] handle=${handle} cleanup disconnect failed: ${(disconnectErr as Error).message}`),
424
612
  );
425
613
  }
426
- // Always try to resume scanning so subsequent connect attempts still see devices.
427
- this.#noble!
428
- .startScanningAsync(["fff6"], true)
429
- .catch(scanErr =>
430
- warn(`[SCAN] failed to resume scanning after connect failure: ${(scanErr as Error).message}`),
431
- );
432
- this.#sendError(id, "internal_error", reason);
614
+ this.#sendError(id, BleProxyErrorCode.ConnectionFailed, reason);
615
+ } finally {
616
+ log(`[SCAN] resuming scan after connect+interview...`);
617
+ await releaseScanPause?.().catch(scanErr =>
618
+ warn(`[SCAN] failed to resume scanning after connect: ${(scanErr as Error).message}`),
619
+ );
433
620
  }
434
621
  }
435
622
 
436
623
  async #handleDisconnect(id: number, connectionHandle: number): Promise<void> {
437
624
  const conn = this.#connections.get(connectionHandle);
438
625
  if (!conn) {
439
- this.#sendError(id, "not_connected", `No connection with handle ${connectionHandle}`);
626
+ this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${connectionHandle}`);
440
627
  return;
441
628
  }
442
629
 
@@ -450,7 +637,7 @@ export class NobleBleProxyClient {
450
637
  async #handleDiscoverServices(id: number, args: DiscoverServicesArgs): Promise<void> {
451
638
  const conn = this.#connections.get(args.connection_handle);
452
639
  if (!conn) {
453
- this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
640
+ this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
454
641
  return;
455
642
  }
456
643
 
@@ -464,11 +651,17 @@ export class NobleBleProxyClient {
464
651
 
465
652
  // Fallback: lazy discover
466
653
  log(`[GATT] handle=${args.connection_handle} discovering services (lazy)...`);
467
- const services = await withTimeout(
468
- conn.peripheral.discoverServicesAsync([]),
469
- 10_000,
470
- "discoverServices timed out after 10s",
471
- );
654
+ let services: Service[];
655
+ try {
656
+ services = await withTimeout(
657
+ conn.peripheral.discoverServicesAsync([]),
658
+ 10_000,
659
+ "discoverServices timed out after 10s",
660
+ );
661
+ } catch (err) {
662
+ this.#sendError(id, BleProxyErrorCode.DiscoveryFailed, (err as Error).message);
663
+ return;
664
+ }
472
665
  for (const service of services) {
473
666
  conn.services.set(service.uuid, service);
474
667
  }
@@ -482,13 +675,13 @@ export class NobleBleProxyClient {
482
675
  async #handleDiscoverCharacteristics(id: number, args: DiscoverCharacteristicsArgs): Promise<void> {
483
676
  const conn = this.#connections.get(args.connection_handle);
484
677
  if (!conn) {
485
- this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
678
+ this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
486
679
  return;
487
680
  }
488
681
 
489
682
  const service = conn.services.get(args.service_uuid);
490
683
  if (!service) {
491
- this.#sendError(id, "service_not_found", `Service ${args.service_uuid} not found`);
684
+ this.#sendError(id, BleProxyErrorCode.ServiceNotFound, `Service ${args.service_uuid} not found`);
492
685
  return;
493
686
  }
494
687
 
@@ -510,11 +703,17 @@ export class NobleBleProxyClient {
510
703
 
511
704
  // Fallback: lazy discover
512
705
  log(`[GATT] handle=${args.connection_handle} discovering characteristics for ${args.service_uuid} (lazy)...`);
513
- const characteristics = await withTimeout(
514
- service.discoverCharacteristicsAsync([]),
515
- 10_000,
516
- `discoverCharacteristics(${args.service_uuid}) timed out after 10s`,
517
- );
706
+ let characteristics: Characteristic[];
707
+ try {
708
+ characteristics = await withTimeout(
709
+ service.discoverCharacteristicsAsync([]),
710
+ 10_000,
711
+ `discoverCharacteristics(${args.service_uuid}) timed out after 10s`,
712
+ );
713
+ } catch (err) {
714
+ this.#sendError(id, BleProxyErrorCode.DiscoveryFailed, (err as Error).message);
715
+ return;
716
+ }
518
717
  for (const char of characteristics) {
519
718
  conn.characteristics.set(char.uuid, char);
520
719
  }
@@ -534,17 +733,31 @@ export class NobleBleProxyClient {
534
733
  async #handleReadCharacteristic(id: number, args: ReadCharacteristicArgs): Promise<void> {
535
734
  const conn = this.#connections.get(args.connection_handle);
536
735
  if (!conn) {
537
- this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
736
+ this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
538
737
  return;
539
738
  }
540
739
 
541
740
  const char = this.#findCharacteristic(conn, args.characteristic_uuid);
542
741
  if (!char) {
543
- this.#sendError(id, "characteristic_not_found", `Characteristic ${args.characteristic_uuid} not found`);
742
+ this.#sendError(
743
+ id,
744
+ BleProxyErrorCode.CharacteristicNotFound,
745
+ `Characteristic ${args.characteristic_uuid} not found`,
746
+ );
544
747
  return;
545
748
  }
546
749
 
547
- const data = await char.readAsync();
750
+ let data: Buffer;
751
+ try {
752
+ data = await char.readAsync();
753
+ } catch (err) {
754
+ this.#sendError(
755
+ id,
756
+ BleProxyErrorCode.ReadFailed,
757
+ `read(${args.characteristic_uuid}): ${(err as Error).message}`,
758
+ );
759
+ return;
760
+ }
548
761
  log(`[GATT] read ${args.characteristic_uuid} → ${data.length} bytes`);
549
762
  this.#sendSuccess(id, { value: Buffer.from(data).toString("base64") });
550
763
  }
@@ -552,20 +765,33 @@ export class NobleBleProxyClient {
552
765
  async #handleWriteCharacteristic(id: number, args: WriteCharacteristicArgs): Promise<void> {
553
766
  const conn = this.#connections.get(args.connection_handle);
554
767
  if (!conn) {
555
- this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
768
+ this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
556
769
  return;
557
770
  }
558
771
 
559
772
  const char = this.#findCharacteristic(conn, args.characteristic_uuid);
560
773
  if (!char) {
561
- this.#sendError(id, "characteristic_not_found", `Characteristic ${args.characteristic_uuid} not found`);
774
+ this.#sendError(
775
+ id,
776
+ BleProxyErrorCode.CharacteristicNotFound,
777
+ `Characteristic ${args.characteristic_uuid} not found`,
778
+ );
562
779
  return;
563
780
  }
564
781
 
565
782
  const data = Buffer.from(args.value, "base64");
566
783
  const withResponse = args.response ?? false;
567
784
  log(`[GATT] write ${args.characteristic_uuid} ${data.length} bytes withResponse=${withResponse}`);
568
- await char.writeAsync(data, !withResponse);
785
+ try {
786
+ await char.writeAsync(data, !withResponse);
787
+ } catch (err) {
788
+ this.#sendError(
789
+ id,
790
+ BleProxyErrorCode.WriteFailed,
791
+ `write(${args.characteristic_uuid}): ${(err as Error).message}`,
792
+ );
793
+ return;
794
+ }
569
795
  conn.lastWriteCharacteristic = char;
570
796
  this.#sendSuccess(id);
571
797
  }
@@ -573,45 +799,123 @@ export class NobleBleProxyClient {
573
799
  async #handleSubscribeCharacteristic(id: number, args: SubscribeCharacteristicArgs): Promise<void> {
574
800
  const conn = this.#connections.get(args.connection_handle);
575
801
  if (!conn) {
576
- this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
802
+ this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
577
803
  return;
578
804
  }
579
805
 
580
806
  const char = this.#findCharacteristic(conn, args.characteristic_uuid);
581
807
  if (!char) {
582
- this.#sendError(id, "characteristic_not_found", `Characteristic ${args.characteristic_uuid} not found`);
808
+ this.#sendError(
809
+ id,
810
+ BleProxyErrorCode.CharacteristicNotFound,
811
+ `Characteristic ${args.characteristic_uuid} not found`,
812
+ );
583
813
  return;
584
814
  }
585
815
 
586
816
  const handle = args.connection_handle;
587
- char.on("data", (data: Buffer) => {
588
- // Send notification as binary frame for efficiency
589
- log(`[GATT] notify ${args.characteristic_uuid} handle=${handle} ${data.length} bytes`);
590
- this.#sendBinaryFrame(BinaryFrameOpcode.Notification, handle, new Uint8Array(data));
591
- });
592
-
593
- await char.subscribeAsync();
594
- conn.subscriptions.set(args.characteristic_uuid, char);
817
+ if (!(await this.#subscribe(id, conn, handle, args.characteristic_uuid, char))) {
818
+ return;
819
+ }
595
820
  log(`[GATT] subscribe ${args.characteristic_uuid} handle=${handle}`);
596
821
  this.#sendSuccess(id);
597
822
  }
598
823
 
824
+ /** No-op when `uuid` is already subscribed. Returns false once an error response was sent. */
825
+ async #subscribe(
826
+ id: number,
827
+ conn: ConnectionState,
828
+ handle: number,
829
+ uuid: string,
830
+ char: Characteristic,
831
+ ): Promise<boolean> {
832
+ // noble's `_notify` resolves without re-enabling CCCD when already notifying, so a
833
+ // second "data" listener would double every forwarded notification.
834
+ const key = NobleBleProxyClient.#subscriptionKey(uuid);
835
+ if (conn.subscriptions.has(key)) {
836
+ return true;
837
+ }
838
+
839
+ // Attached before the CCCD enable: noble drops "data" emitted with no listener attached.
840
+ const onData = this.#notificationForwarder(handle, uuid);
841
+ char.on("data", onData);
842
+ try {
843
+ await char.subscribeAsync();
844
+ } catch (err) {
845
+ char.removeListener("data", onData);
846
+ this.#sendError(id, BleProxyErrorCode.SubscribeFailed, `subscribe(${uuid}): ${(err as Error).message}`);
847
+ return false;
848
+ }
849
+ conn.subscriptions.set(key, { characteristic: char, onData });
850
+ return true;
851
+ }
852
+
853
+ async #handleWriteAndSubscribe(id: number, args: WriteAndSubscribeArgs): Promise<void> {
854
+ const conn = this.#connections.get(args.connection_handle);
855
+ if (!conn) {
856
+ this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
857
+ return;
858
+ }
859
+
860
+ const writeChar = this.#findCharacteristic(conn, args.write_uuid);
861
+ if (!writeChar) {
862
+ this.#sendError(
863
+ id,
864
+ BleProxyErrorCode.CharacteristicNotFound,
865
+ `Characteristic ${args.write_uuid} not found`,
866
+ );
867
+ return;
868
+ }
869
+
870
+ const subscribeChar = this.#findCharacteristic(conn, args.subscribe_uuid);
871
+ if (!subscribeChar) {
872
+ this.#sendError(
873
+ id,
874
+ BleProxyErrorCode.CharacteristicNotFound,
875
+ `Characteristic ${args.subscribe_uuid} not found`,
876
+ );
877
+ return;
878
+ }
879
+
880
+ const handle = args.connection_handle;
881
+ const value = Buffer.from(args.write_value, "base64");
882
+ const withResponse = args.write_response ?? false;
883
+ log(
884
+ `[GATT] write_and_subscribe handle=${handle} write=${args.write_uuid} ${value.length} bytes` +
885
+ ` withResponse=${withResponse} subscribe=${args.subscribe_uuid}`,
886
+ );
887
+
888
+ try {
889
+ await writeChar.writeAsync(value, !withResponse);
890
+ } catch (err) {
891
+ this.#sendError(id, BleProxyErrorCode.WriteFailed, `write(${args.write_uuid}): ${(err as Error).message}`);
892
+ return;
893
+ }
894
+ conn.lastWriteCharacteristic = writeChar;
895
+
896
+ if (!(await this.#subscribe(id, conn, handle, args.subscribe_uuid, subscribeChar))) {
897
+ return;
898
+ }
899
+ this.#sendSuccess(id);
900
+ }
901
+
599
902
  async #handleUnsubscribeCharacteristic(id: number, args: UnsubscribeCharacteristicArgs): Promise<void> {
600
903
  const conn = this.#connections.get(args.connection_handle);
601
904
  if (!conn) {
602
- this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
905
+ this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
603
906
  return;
604
907
  }
605
908
 
606
- const char = conn.subscriptions.get(args.characteristic_uuid);
607
- if (!char) {
608
- this.#sendError(id, "not_subscribed", `Not subscribed to ${args.characteristic_uuid}`);
909
+ const key = NobleBleProxyClient.#subscriptionKey(args.characteristic_uuid);
910
+ const subscription = conn.subscriptions.get(key);
911
+ if (!subscription) {
912
+ this.#sendError(id, BleProxyErrorCode.NotSubscribed, `Not subscribed to ${args.characteristic_uuid}`);
609
913
  return;
610
914
  }
611
915
 
612
- await char.unsubscribeAsync();
613
- char.removeAllListeners("data");
614
- conn.subscriptions.delete(args.characteristic_uuid);
916
+ await subscription.characteristic.unsubscribeAsync();
917
+ subscription.characteristic.removeListener("data", subscription.onData);
918
+ conn.subscriptions.delete(key);
615
919
  log(`[GATT] unsubscribe ${args.characteristic_uuid} handle=${args.connection_handle}`);
616
920
  this.#sendSuccess(id);
617
921
  }
@@ -619,7 +923,7 @@ export class NobleBleProxyClient {
619
923
  async #handleRequestMtu(id: number, connectionHandle: number, mtu: number): Promise<void> {
620
924
  const conn = this.#connections.get(connectionHandle);
621
925
  if (!conn) {
622
- this.#sendError(id, "not_connected", `No connection with handle ${connectionHandle}`);
926
+ this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${connectionHandle}`);
623
927
  return;
624
928
  }
625
929
  // Noble doesn't have explicit MTU request - return the peripheral's MTU
@@ -631,7 +935,14 @@ export class NobleBleProxyClient {
631
935
  // ─── Binary Frame Handling ───────────────────────────────────────────────
632
936
 
633
937
  #handleBinaryFrame(data: Buffer): void {
634
- const frame = decodeBinaryFrame(new Uint8Array(data));
938
+ // A throw here escapes into ws's receiver and takes the process down with it.
939
+ let frame;
940
+ try {
941
+ frame = decodeBinaryFrame(new Uint8Array(data));
942
+ } catch (err) {
943
+ warn(`[←BIN] failed to decode binary frame: ${(err as Error).message}`);
944
+ return;
945
+ }
635
946
  log(`[←BIN] opcode=${frame.opcode} handle=${frame.connectionHandle} payload=${frame.payload.length} bytes`);
636
947
 
637
948
  if (frame.opcode === BinaryFrameOpcode.WriteData) {
@@ -650,6 +961,100 @@ export class NobleBleProxyClient {
650
961
 
651
962
  // ─── Helpers ─────────────────────────────────────────────────────────────
652
963
 
964
+ /** Records the scan the server wants, then drives noble to it. Resolves once that settled. */
965
+ #requestScan(config: ScanConfig | undefined): Promise<void> {
966
+ this.#desiredScan = config;
967
+ return this.#reconcileScan(++this.#scanGeneration);
968
+ }
969
+
970
+ /**
971
+ * Suspends the running scan without forgetting it, so a resume restores the server's filter.
972
+ * Counted, not a flag: `connect` commands overlap, and the first to finish must not resume a
973
+ * scan while another is still interviewing — on macOS that reinstates the discovery hang the
974
+ * pause exists to prevent. Returns the release, which is safe to call more than once.
975
+ */
976
+ async #acquireScanPause(): Promise<() => Promise<void>> {
977
+ this.#scanPauses++;
978
+ let released = false;
979
+ const release = async () => {
980
+ if (released) {
981
+ return;
982
+ }
983
+ released = true;
984
+ this.#scanPauses--;
985
+ await this.#reconcileScan(++this.#scanGeneration);
986
+ };
987
+
988
+ try {
989
+ await this.#reconcileScan(++this.#scanGeneration);
990
+ } catch (err) {
991
+ await release();
992
+ throw err;
993
+ }
994
+ return release;
995
+ }
996
+
997
+ /**
998
+ * Drives noble to the state `generation` asked for, or returns once a later request has taken
999
+ * ownership of the outcome. noble registers each scan call's completion callback with
1000
+ * `onceExclusive`, which removes the previously registered one, so two overlapping calls leave
1001
+ * the earlier promise unsettled forever — every scan call is therefore serialized here.
1002
+ */
1003
+ #reconcileScan(generation: number): Promise<void> {
1004
+ return this.#serializeScanOperation(async () => {
1005
+ if (generation !== this.#scanGeneration) {
1006
+ return;
1007
+ }
1008
+ const target = this.#scanPauses > 0 ? undefined : this.#desiredScan;
1009
+ // The hci-socket binding returns early from startScanning while a scan is running,
1010
+ // keeping the previous service-uuid filter, so a filter change needs a stop first.
1011
+ await this.#callNoble(noble => noble.stopScanningAsync(), "stopScanning");
1012
+ if (target) {
1013
+ // Noble is always asked for duplicates; `allow_duplicates` is honoured when reporting.
1014
+ await this.#callNoble(noble => noble.startScanningAsync(target.serviceUuids, true), "startScanning");
1015
+ }
1016
+ });
1017
+ }
1018
+
1019
+ #serializeScanOperation<T>(operation: () => Promise<T>): Promise<T> {
1020
+ const result = this.#scanOperations.then(operation);
1021
+ this.#scanOperations = result.catch(() => undefined);
1022
+ return result;
1023
+ }
1024
+
1025
+ async #callNoble(call: (noble: NobleApi) => Promise<void>, description: string): Promise<void> {
1026
+ const noble = this.#noble;
1027
+ if (!noble) {
1028
+ return;
1029
+ }
1030
+ const pending = call(noble);
1031
+ // Rejecting after the timeout has already won the race still needs a handler here.
1032
+ pending.catch(() => undefined);
1033
+ await withTimeout(
1034
+ pending,
1035
+ this.#scanOperationTimeoutMs,
1036
+ `${description} did not settle within ${this.#scanOperationTimeoutMs}ms`,
1037
+ );
1038
+ }
1039
+
1040
+ // Forwarded regardless of noble's `isNotification`: on macOS that flag is derived from a
1041
+ // manager-wide pendingRead, so it reads false for a notification racing a read on any
1042
+ // peripheral. Safe only while no subscribed characteristic is ever read.
1043
+ #notificationForwarder(handle: number, uuid: string): NotificationListener {
1044
+ return payload => {
1045
+ if (payload === null) {
1046
+ return;
1047
+ }
1048
+ log(`[GATT] notify ${uuid} handle=${handle} ${payload.length} bytes`);
1049
+ this.#sendBinaryFrame(BinaryFrameOpcode.Notification, handle, new Uint8Array(payload));
1050
+ };
1051
+ }
1052
+
1053
+ /** Subscription key; `#findCharacteristic` resolves spellings that differ only in case or dashes. */
1054
+ static #subscriptionKey(uuid: string): string {
1055
+ return uuid.replace(/-/g, "").toLowerCase();
1056
+ }
1057
+
653
1058
  #findCharacteristic(conn: ConnectionState, uuid: string): Characteristic | undefined {
654
1059
  // Try exact match first, then case-insensitive
655
1060
  return (
@@ -663,7 +1068,7 @@ export class NobleBleProxyClient {
663
1068
  this.#ws?.send(JSON.stringify({ id, success: true, result: result ?? {} }));
664
1069
  }
665
1070
 
666
- #sendError(id: number, error: string, message: string): void {
1071
+ #sendError(id: number, error: BleProxyErrorCodeValue, message: string): void {
667
1072
  this.#ws?.send(JSON.stringify({ id, success: false, error, message }));
668
1073
  }
669
1074