@matter-server/ble-proxy 0.7.2-alpha.0-20260602-cef59f5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -78,16 +78,20 @@ export class ProxyBleCentralInterface implements Transport {
78
78
  }
79
79
 
80
80
  const { peripheralAddress } = address;
81
- const { hasAdditionalAdvertisementData } = this.#bleScanner.getDiscoveredDevice(peripheralAddress);
82
81
 
83
- if (!this.#handler.connected) {
84
- throw new BleError("BLE proxy client not connected");
82
+ const connection = this.#handler.getOwner(peripheralAddress);
83
+ if (!connection) {
84
+ throw new BleError(`No connected BLE proxy client owns peripheral ${peripheralAddress}`);
85
85
  }
86
86
 
87
- logger.debug(`Connecting to peripheral ${peripheralAddress} via proxy`);
87
+ const discovered = this.#bleScanner.getDiscoveredDevice(peripheralAddress);
88
+ const { hasAdditionalAdvertisementData } = discovered;
89
+ const rssi = discovered.peripheral.rssi;
90
+
91
+ logger.debug(`Connecting to peripheral ${peripheralAddress} (rssi=${rssi ?? "n/a"}) via proxy`);
88
92
 
89
93
  // 1. Connect
90
- const { connection_handle, mtu: peripheralMtu } = await this.#handler.sendCommand(BleProxyCommand.Connect, {
94
+ const { connection_handle, mtu: peripheralMtu } = await connection.sendCommand(BleProxyCommand.Connect, {
91
95
  address: peripheralAddress,
92
96
  });
93
97
 
@@ -95,11 +99,17 @@ export class ProxyBleCentralInterface implements Transport {
95
99
  if (mtu > MatterBle.MAXIMUM_BTP_MTU) {
96
100
  mtu = MatterBle.MAXIMUM_BTP_MTU;
97
101
  }
98
- logger.debug(`Connected to ${peripheralAddress}, handle=${connection_handle}, mtu=${mtu}`);
102
+ logger.info(
103
+ `Connected to ${peripheralAddress}, handle=${connection_handle}, mtu=${mtu}, rssi=${rssi ?? "n/a"}`,
104
+ );
105
+
106
+ // The owner connection's observables outlive a failed open; a leaked observer would corrupt
107
+ // the next channel on the same connection. Detach on every exit path; assigned once registered.
108
+ let detachObservers: (() => void) | undefined;
99
109
 
100
110
  try {
101
111
  // 2. Discover services
102
- const { services } = await this.#handler.sendCommand(BleProxyCommand.DiscoverServices, {
112
+ const { services } = await connection.sendCommand(BleProxyCommand.DiscoverServices, {
103
113
  connection_handle,
104
114
  });
105
115
 
@@ -109,7 +119,7 @@ export class ProxyBleCentralInterface implements Transport {
109
119
  }
110
120
 
111
121
  // 3. Discover characteristics
112
- const { characteristics } = await this.#handler.sendCommand(BleProxyCommand.DiscoverCharacteristics, {
122
+ const { characteristics } = await connection.sendCommand(BleProxyCommand.DiscoverCharacteristics, {
113
123
  connection_handle,
114
124
  service_uuid: matterService.uuid,
115
125
  });
@@ -136,34 +146,15 @@ export class ProxyBleCentralInterface implements Transport {
136
146
  // 4. Read C3 if present and has additional data
137
147
  if (c3Uuid && hasAdditionalAdvertisementData) {
138
148
  logger.debug(`Reading additional commissioning data from C3`);
139
- await this.#handler.sendCommand(BleProxyCommand.ReadCharacteristic, {
149
+ await connection.sendCommand(BleProxyCommand.ReadCharacteristic, {
140
150
  connection_handle,
141
151
  characteristic_uuid: c3Uuid,
142
152
  });
143
- // Additional commissioning data read but not used directly by the proxy -
144
- // it's handled by matter.js commissioning flow internally
145
153
  }
146
154
 
147
- // 5. Send BTP handshake request on C1 and atomically subscribe to C2 for the
148
- // response indication. The combo eliminates the WebSocket round-trip between
149
- // Write Response and CCCD enable — without it, a peripheral that pushes the
150
- // handshake indication immediately after Write Response can fire before the
151
- // proxy client has enabled notifications, and the indication is lost.
152
- const btpHandshakeRequest = BtpCodec.encodeBtpHandshakeRequest({
153
- versions: MatterBle.BTP_SUPPORTED_VERSIONS,
154
- attMtu: mtu,
155
- clientWindowSize: MatterBle.BTP_MAXIMUM_WINDOW_SIZE,
156
- });
157
- logger.debug(`Sending BTP handshake request on C1 and subscribing C2 atomically`);
158
- await this.#handler.sendCommand(BleProxyCommand.WriteAndSubscribe, {
159
- connection_handle,
160
- write_uuid: c1Uuid,
161
- write_value: Buffer.from(btpHandshakeRequest as ArrayBuffer).toString("base64"),
162
- write_response: true,
163
- subscribe_uuid: c2Uuid,
164
- });
165
-
166
- // 6. Wait for BTP handshake response via binary frame
155
+ // 5. Register the handshake observer BEFORE sending: the C2 indication is a separate
156
+ // binary frame that can beat the WriteAndSubscribe response, and binaryFrameReceived
157
+ // drops frames emitted with no listener attached.
167
158
  const {
168
159
  promise: handshakePromise,
169
160
  resolver: handshakeResolver,
@@ -191,34 +182,74 @@ export class ProxyBleCentralInterface implements Transport {
191
182
  }
192
183
  }
193
184
  };
194
- this.#handler.binaryFrameReceived.on(handshakeObserver);
185
+ connection.binaryFrameReceived.on(handshakeObserver);
186
+
187
+ // 6. Write C1 and subscribe C2 atomically so the peripheral can't fire its indication
188
+ // before notifications are enabled (no round-trip between Write Response and CCCD enable).
189
+ const btpHandshakeRequest = BtpCodec.encodeBtpHandshakeRequest({
190
+ versions: MatterBle.BTP_SUPPORTED_VERSIONS,
191
+ attMtu: mtu,
192
+ clientWindowSize: MatterBle.BTP_MAXIMUM_WINDOW_SIZE,
193
+ });
194
+ logger.debug(`Sending BTP handshake request on C1 and subscribing C2 atomically`);
195
195
 
196
196
  let handshakeResponse: Uint8Array;
197
197
  try {
198
+ await connection.sendCommand(BleProxyCommand.WriteAndSubscribe, {
199
+ connection_handle,
200
+ write_uuid: c1Uuid,
201
+ write_value: Buffer.from(btpHandshakeRequest as ArrayBuffer).toString("base64"),
202
+ write_response: true,
203
+ subscribe_uuid: c2Uuid,
204
+ });
198
205
  handshakeResponse = await handshakePromise;
199
206
  } finally {
200
- this.#handler.binaryFrameReceived.off(handshakeObserver);
207
+ connection.binaryFrameReceived.off(handshakeObserver);
201
208
  btpHandshakeTimeout.stop();
202
209
  }
203
210
 
204
- // 7. Create BTP session
205
- // Use a ref object so closures can access the channel after it's created
211
+ // 7. Register the live observer BEFORE creating the session (same non-buffering frame
212
+ // race as step 5); frames seen before the session exists are buffered, then flushed below.
206
213
  const onMatterMessageListener = this.#onMatterMessageListener;
207
214
  const channelRef: { channel?: ProxyBleChannel } = {};
215
+ const sessionRef: { session?: BtpSessionHandler } = {};
216
+ const earlyFrames = new Array<Uint8Array>();
217
+
218
+ const forwardToBtp = (payload: Uint8Array) => {
219
+ sessionRef.session
220
+ ?.handleIncomingBleData(payload)
221
+ .catch(error =>
222
+ logger.warn(`Peripheral ${peripheralAddress}: Error handling incoming BLE data`, error),
223
+ );
224
+ };
208
225
 
226
+ const binaryObserver = (frame: { connectionHandle: number; opcode: number; payload: Uint8Array }) => {
227
+ if (frame.connectionHandle === connection_handle && frame.opcode === BinaryFrameOpcode.Notification) {
228
+ const payload = new Uint8Array(frame.payload);
229
+ if (sessionRef.session) {
230
+ forwardToBtp(payload);
231
+ } else {
232
+ earlyFrames.push(payload);
233
+ }
234
+ }
235
+ };
236
+ connection.binaryFrameReceived.on(binaryObserver);
237
+ detachObservers = () => connection.binaryFrameReceived.off(binaryObserver);
238
+
239
+ // 8. Create the BTP session.
209
240
  const btpSession = await BtpSessionHandler.createAsCentral(
210
241
  handshakeResponse,
211
242
  // Write callback: send binary frame to proxy client
212
243
  async (data: Bytes) => {
213
244
  const bytes = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
214
- this.#handler.sendBinaryFrame(BinaryFrameOpcode.WriteData, connection_handle, bytes);
245
+ connection.sendBinaryFrame(BinaryFrameOpcode.WriteData, connection_handle, bytes);
215
246
  },
216
247
  // Disconnect callback
217
248
  async () => {
218
249
  if (!channelRef.channel?.connected) return;
219
250
  logger.debug(`Disconnecting from ${peripheralAddress} via proxy`);
220
251
  try {
221
- await this.#handler.sendCommand(BleProxyCommand.Disconnect, { connection_handle });
252
+ await connection.sendCommand(BleProxyCommand.Disconnect, { connection_handle });
222
253
  } catch (error) {
223
254
  logger.debug(
224
255
  `Peripheral ${peripheralAddress}: Error sending Disconnect to proxy client`,
@@ -234,18 +265,12 @@ export class ProxyBleCentralInterface implements Transport {
234
265
  }
235
266
  },
236
267
  );
268
+ sessionRef.session = btpSession;
237
269
 
238
- // 8. Wire up binary frame notifications to BTP session
239
- const binaryObserver = (frame: { connectionHandle: number; opcode: number; payload: Uint8Array }) => {
240
- if (frame.connectionHandle === connection_handle && frame.opcode === BinaryFrameOpcode.Notification) {
241
- btpSession
242
- .handleIncomingBleData(new Uint8Array(frame.payload))
243
- .catch(error =>
244
- logger.warn(`Peripheral ${peripheralAddress}: Error handling incoming BLE data`, error),
245
- );
246
- }
247
- };
248
- this.#handler.binaryFrameReceived.on(binaryObserver);
270
+ for (const payload of earlyFrames) {
271
+ forwardToBtp(payload);
272
+ }
273
+ earlyFrames.length = 0;
249
274
 
250
275
  // 9. Handle unexpected disconnects from proxy client
251
276
  const eventObserver = (event: string, data: Record<string, unknown>) => {
@@ -261,21 +286,32 @@ export class ProxyBleCentralInterface implements Transport {
261
286
  .catch(error => logger.debug(`Peripheral ${peripheralAddress}: Error closing channel`, error));
262
287
  }
263
288
  };
264
- this.#handler.eventReceived.on(eventObserver);
289
+ connection.eventReceived.on(eventObserver);
290
+
291
+ // The Disconnected event covers one peripheral; this covers the whole owning client vanishing.
292
+ const ownerClosedObserver = () => {
293
+ logger.info(`Owning proxy client for ${peripheralAddress} disconnected`);
294
+ channelRef.channel?.markDisconnected();
295
+ channelRef.channel
296
+ ?.close()
297
+ .catch(error => logger.debug(`Peripheral ${peripheralAddress}: Error closing channel`, error));
298
+ };
299
+ connection.closed.on(ownerClosedObserver);
265
300
 
266
- // Cleanup function to remove observers when channel closes
267
- const cleanupObservers = () => {
268
- this.#handler.binaryFrameReceived.off(binaryObserver);
269
- this.#handler.eventReceived.off(eventObserver);
301
+ detachObservers = () => {
302
+ connection.binaryFrameReceived.off(binaryObserver);
303
+ connection.eventReceived.off(eventObserver);
304
+ connection.closed.off(ownerClosedObserver);
270
305
  };
271
306
 
272
- const proxyChannel = new ProxyBleChannel(peripheralAddress, btpSession, cleanupObservers);
307
+ const proxyChannel = new ProxyBleChannel(peripheralAddress, btpSession, detachObservers);
273
308
  channelRef.channel = proxyChannel;
274
309
  return proxyChannel;
275
310
  } catch (error) {
276
- // Clean up on failure — best-effort tear-down of the peripheral on the proxy side
311
+ // Clean up on failure — detach observers and best-effort tear-down on the proxy side
312
+ detachObservers?.();
277
313
  try {
278
- await this.#handler.sendCommand(BleProxyCommand.Disconnect, { connection_handle });
314
+ await connection.sendCommand(BleProxyCommand.Disconnect, { connection_handle });
279
315
  } catch (cleanupError) {
280
316
  logger.debug(`Peripheral ${peripheralAddress}: Error during connect-failure cleanup`, cleanupError);
281
317
  }
@@ -6,8 +6,9 @@
6
6
 
7
7
  import { createPromise, Logger, Seconds, withTimeout } from "@matter/main";
8
8
  import { BleError, MatterBle } from "@matter/main/protocol";
9
+ import type { BleProxyConnection } from "./BleProxyConnection.js";
9
10
  import type { BleProxyHandler } from "./BleProxyHandler.js";
10
- import { BleProxyCommand, BleProxyEvent, type DeviceDiscoveredData } from "./BleProxyProtocol.js";
11
+ import type { DeviceDiscoveredData } from "./BleProxyProtocol.js";
11
12
 
12
13
  const logger = Logger.get("ProxyBleClient");
13
14
 
@@ -43,18 +44,19 @@ export class ProxyBleClient {
43
44
  #deviceDiscoveredCallback: ((peripheral: ProxyPeripheral, matterServiceData: Uint8Array) => void) | undefined;
44
45
  #isScanning = false;
45
46
 
46
- readonly #eventObserver = (event: string, data: Record<string, unknown>) => {
47
- if (event === BleProxyEvent.DeviceDiscovered) {
48
- this.#handleDeviceDiscovered(data as unknown as DeviceDiscoveredData);
49
- } else if (event === BleProxyEvent.ScanStopped) {
50
- logger.info(`Scan stopped by proxy client: ${(data as { reason?: string }).reason ?? "unknown"}`);
51
- this.#isScanning = false;
52
- }
47
+ readonly #deviceDiscoveredObserver = (data: DeviceDiscoveredData, _connection: BleProxyConnection) => {
48
+ this.#handleDeviceDiscovered(data);
49
+ };
50
+
51
+ readonly #scanStoppedObserver = (reason: string) => {
52
+ logger.info(`Scan stopped by proxy client: ${reason}`);
53
+ this.#isScanning = false;
53
54
  };
54
55
 
55
56
  constructor(handler: BleProxyHandler) {
56
57
  this.#handler = handler;
57
- this.#handler.eventReceived.on(this.#eventObserver);
58
+ this.#handler.deviceDiscovered.on(this.#deviceDiscoveredObserver);
59
+ this.#handler.scanStopped.on(this.#scanStoppedObserver);
58
60
  }
59
61
 
60
62
  setDiscoveryCallback(callback: (peripheral: ProxyPeripheral, matterServiceData: Uint8Array) => void): void {
@@ -88,31 +90,23 @@ export class ProxyBleClient {
88
90
  logger.debug("Start BLE scanning via proxy ...");
89
91
  // Matter discovery only needs one event per state change; opt out of the spec's
90
92
  // default true so a 10 Hz peripheral advertise doesn't flood the WebSocket.
91
- await this.#handler.sendCommand(BleProxyCommand.StartScan, {
92
- service_uuids: ["fff6"],
93
- allow_duplicates: false,
94
- });
93
+ await this.#handler.startScan({ service_uuids: ["fff6"], allow_duplicates: false });
95
94
  this.#isScanning = true;
96
95
  }
97
96
 
98
97
  async stopScanning(): Promise<void> {
99
- if (!this.#isScanning) {
100
- return;
101
- }
102
-
103
- if (!this.#handler.connected) {
104
- this.#isScanning = false;
105
- return;
106
- }
107
-
108
- logger.debug("Stop BLE scanning via proxy ...");
109
- await this.#handler.sendCommand(BleProxyCommand.StopScan);
98
+ // Clear hub scan intent unconditionally: a transient all-clients-disconnect can reset
99
+ // #isScanning while the hub still intends to scan, and skipping stopScan here would let
100
+ // a reconnecting client auto-resume a scan the caller already ended.
110
101
  this.#isScanning = false;
102
+ logger.debug("Stop BLE scanning via proxy ...");
103
+ await this.#handler.stopScan();
111
104
  }
112
105
 
113
106
  close(): void {
114
107
  this.#isScanning = false;
115
- this.#handler.eventReceived.off(this.#eventObserver);
108
+ this.#handler.deviceDiscovered.off(this.#deviceDiscoveredObserver);
109
+ this.#handler.scanStopped.off(this.#scanStoppedObserver);
116
110
  }
117
111
 
118
112
  #handleDeviceDiscovered(data: DeviceDiscoveredData): void {
@@ -150,7 +144,9 @@ export class ProxyBleClient {
150
144
  return;
151
145
  }
152
146
 
153
- logger.debug(`Discovered device ${address} (${name ?? "unnamed"}) via proxy`);
147
+ logger.info(
148
+ `Discovered commissionable device ${address} (${name ?? "unnamed"}) rssi=${rssi ?? "n/a"} via proxy`,
149
+ );
154
150
  this.#discoveredPeripherals.set(address, { peripheral, matterServiceData });
155
151
  this.#deviceDiscoveredCallback?.(peripheral, matterServiceData);
156
152
  }
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * @matter-server/ble-proxy - BLE proxy for matter.js over WebSocket
9
9
  */
10
10
 
11
+ export * from "./BleProxyConnection.js";
11
12
  export * from "./BleProxyHandler.js";
12
13
  export * from "./BleProxyProtocol.js";
13
14
  export * from "./ProxyBle.js";