@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.
@@ -5,79 +5,54 @@
5
5
  */
6
6
 
7
7
  import type { WebServerHandler } from "@matter-server/ws-controller";
8
- import { createPromise, Logger, Observable, Seconds, Time, Timer, withTimeout } from "@matter/main";
8
+ import { Logger, Observable } from "@matter/main";
9
9
  import type { createServer } from "node:http";
10
10
  import { WebSocket, WebSocketServer } from "ws";
11
- import {
12
- BLE_PROXY_PROTOCOL_VERSION,
13
- decodeBinaryFrame,
14
- encodeBinaryFrame,
15
- type BinaryFrame,
16
- type BleProxyCommandMap,
17
- type BleProxyCommandName,
18
- type BleProxyEventName,
19
- type CommandMessage,
20
- type EventMessage,
21
- type HelloMessage,
22
- type ResponseMessage,
23
- } from "./BleProxyProtocol.js";
11
+ import { BleProxyConnection } from "./BleProxyConnection.js";
12
+ import { BleProxyCommand, BleProxyEvent, type DeviceDiscoveredData, type StartScanArgs } from "./BleProxyProtocol.js";
24
13
 
25
14
  type HttpServer = ReturnType<typeof createServer>;
26
15
 
27
16
  const logger = Logger.get("BleProxyHandler");
28
17
 
29
- const HANDSHAKE_TIMEOUT = Seconds(10);
30
18
  /**
31
- * Default per-command timeout. Long enough to cover the slowest BLE
32
- * commissioning ops (peripheral connect ≤ 30 s, service discovery on
33
- * Android-side adapters can take several seconds), short enough that an
34
- * unresponsive-but-still-connected proxy client doesn't hang commissioning
35
- * indefinitely.
36
- */
37
- const COMMAND_TIMEOUT = Seconds(60);
38
-
39
- /**
40
- * WebSocket server handler for the /ble BLE proxy endpoint.
19
+ * WebSocket server handler (hub) for the `/ble` BLE proxy endpoint.
41
20
  *
42
- * Accepts a single client connection, performs the protocol handshake,
43
- * and routes commands/events/binary frames between the proxy implementation
44
- * and the connected BLE proxy client.
21
+ * Accepts any number of proxy client connections, broadcasts scan commands to
22
+ * all of them, and tracks which client owns each discovered peripheral so that
23
+ * per-peripheral traffic routes to a single client.
45
24
  */
46
25
  export class BleProxyHandler implements WebServerHandler {
47
26
  #wss?: WebSocketServer;
48
27
  /** Upgrade listener removers, one per HTTP server `register()` call (multi-bind). */
49
28
  #removeUpgradeListeners: (() => void)[] = [];
50
- #client?: WebSocket;
51
- #handshakeComplete = false;
52
- #pendingCommands = new Map<
53
- number,
54
- { resolver: (result: Record<string, unknown> | undefined) => void; rejecter: (reason?: unknown) => void }
55
- >();
56
- /** Command ID counter. Rolls over at 0xFFFF to stay in safe integer range. */
57
- #nextCommandId = 0;
29
+ #connections = new Set<BleProxyConnection>();
58
30
  #closed = false;
59
31
 
60
- /** Emitted when a JSON event is received from the BLE proxy client. */
61
- readonly eventReceived = new Observable<[event: BleProxyEventName, data: Record<string, unknown>]>();
32
+ /** Active scan intent + args, so clients joining mid-scan can be synced. */
33
+ #scanActive = false;
34
+ #scanArgs?: StartScanArgs;
35
+ /** Connections currently told to scan, for aggregate `scanStopped`. */
36
+ #scanning = new Set<BleProxyConnection>();
62
37
 
63
- /** Emitted when a binary frame is received from the BLE proxy client. */
64
- readonly binaryFrameReceived = new Observable<[frame: BinaryFrame]>();
38
+ /** address -> owning connection + every connection that has seen it. */
39
+ #owners = new Map<string, { owner: BleProxyConnection; seers: Set<BleProxyConnection> }>();
65
40
 
66
- /** Emitted once a proxy client has completed the handshake and `connected` is true. */
41
+ /** Emitted whenever a connection completes its handshake. */
67
42
  readonly connectionEstablished = new Observable<[]>();
43
+ /** Emitted for each `device_discovered`, after ownership is updated. */
44
+ readonly deviceDiscovered = new Observable<[data: DeviceDiscoveredData, connection: BleProxyConnection]>();
45
+ /** Emitted once no connection is scanning anymore. */
46
+ readonly scanStopped = new Observable<[reason: string]>();
68
47
 
69
48
  get connected(): boolean {
70
- return this.#handshakeComplete && this.#client?.readyState === WebSocket.OPEN;
49
+ for (const c of this.#connections) {
50
+ if (c.connected) return true;
51
+ }
52
+ return false;
71
53
  }
72
54
 
73
55
  async register(server: HttpServer): Promise<void> {
74
- // Use noServer mode with a path-filtered upgrade listener.
75
- // ws 8.x calls handleUpgrade unconditionally from its own upgrade listener, which
76
- // sends HTTP 400 for non-matching paths and destroys the socket — breaking other
77
- // WebSocket endpoints on the same server.
78
- //
79
- // Reuse a single WSS across all bound HTTP servers (multi-listen-address): create
80
- // it once, then attach a per-server upgrade listener that forwards into it.
81
56
  const isFirstBind = this.#wss === undefined;
82
57
  const wss = (this.#wss ??= new WebSocketServer({ noServer: true }));
83
58
  const upgradeHandler = (
@@ -111,43 +86,12 @@ export class BleProxyHandler implements WebServerHandler {
111
86
  return;
112
87
  }
113
88
 
114
- if (this.#client) {
115
- logger.warn("Rejecting new BLE proxy client - only one connection allowed");
116
- ws.close(4000, "Only one BLE proxy client allowed");
117
- return;
118
- }
119
-
120
- logger.info("BLE proxy client connected, waiting for handshake");
121
- this.#client = ws;
122
- this.#handshakeComplete = false;
123
-
124
- const handshakeTimer = Time.getTimer("BLE proxy handshake timeout", HANDSHAKE_TIMEOUT, () => {
125
- if (!this.#handshakeComplete) {
126
- logger.warn("BLE proxy handshake timeout - closing connection");
127
- ws.close(4001, "Handshake timeout");
128
- this.#cleanupClient();
129
- }
130
- }).start();
89
+ const connection = new BleProxyConnection(ws);
90
+ this.#connections.add(connection);
131
91
 
132
- ws.on("message", (data, isBinary) => {
133
- if (isBinary) {
134
- this.#handleBinaryMessage(data as Buffer);
135
- } else {
136
- this.#handleTextMessage(data.toString(), handshakeTimer);
137
- }
138
- });
139
-
140
- ws.on("close", () => {
141
- handshakeTimer.stop();
142
- logger.info("BLE proxy client disconnected");
143
- this.#cleanupClient();
144
- });
145
-
146
- ws.on("error", err => {
147
- logger.error("BLE proxy WebSocket error:", err);
148
- handshakeTimer.stop();
149
- this.#cleanupClient();
150
- });
92
+ connection.handshakeCompleted.on(() => this.#onHandshakeCompleted(connection));
93
+ connection.eventReceived.on((event, data) => this.#onConnectionEvent(connection, event, data));
94
+ connection.closed.on(() => this.#onConnectionClosed(connection));
151
95
  });
152
96
  }
153
97
 
@@ -160,204 +104,135 @@ export class BleProxyHandler implements WebServerHandler {
160
104
  for (const remove of this.#removeUpgradeListeners) remove();
161
105
  this.#removeUpgradeListeners = [];
162
106
 
163
- // Close the connected client
164
- if (this.#client && this.#client.readyState === WebSocket.OPEN) {
165
- this.#client.close();
107
+ for (const connection of this.#connections) {
108
+ connection.close();
166
109
  }
167
- this.#cleanupClient();
110
+ this.#connections.clear();
111
+ this.#scanning.clear();
112
+ this.#owners.clear();
168
113
 
169
114
  const wss = this.#wss;
170
115
  this.#wss = undefined;
171
116
 
172
- // Close all clients connected to the WSS (safety net)
173
117
  wss.clients.forEach(client => {
174
118
  if (client.readyState === WebSocket.OPEN) {
175
119
  client.close();
176
120
  }
177
121
  });
178
122
 
179
- // Wait for the WebSocket server to close properly
180
123
  return new Promise<void>((resolve, reject) => {
181
- wss.close(err => {
182
- if (err) {
183
- reject(err);
184
- } else {
185
- resolve();
186
- }
187
- });
124
+ wss.close(err => (err ? reject(err) : resolve()));
188
125
  });
189
126
  }
190
127
 
191
- /**
192
- * Send a typed command to the BLE proxy client and wait for the response.
193
- * The return type is inferred from the command name via BleProxyCommandMap.
194
- * Rejects if no client is connected or the client disconnects before responding.
195
- */
196
- async sendCommand<C extends BleProxyCommandName>(
197
- command: C,
198
- ...rest: BleProxyCommandMap[C]["args"] extends undefined ? [] : [args: BleProxyCommandMap[C]["args"]]
199
- ): Promise<BleProxyCommandMap[C]["result"]> {
200
- if (!this.connected || !this.#client) {
201
- throw new Error("BLE proxy client not connected");
202
- }
203
-
204
- const args = rest[0];
205
- const id = this.#nextCommandId;
206
- this.#nextCommandId = (this.#nextCommandId + 1) & 0xffff;
207
- const message: CommandMessage = { id, command };
208
- if (args !== undefined) {
209
- message.args = args as Record<string, unknown>;
210
- }
211
-
212
- const { promise, resolver, rejecter } = createPromise<Record<string, unknown> | undefined>();
213
- this.#pendingCommands.set(id, { resolver, rejecter });
214
-
215
- this.#client.send(JSON.stringify(message), err => {
216
- if (err) {
217
- this.#pendingCommands.delete(id);
218
- rejecter(new Error(`Failed to send command ${command}: ${err.message}`));
219
- }
220
- });
221
-
222
- return withTimeout(COMMAND_TIMEOUT, promise, () => this.#pendingCommands.delete(id)) as Promise<
223
- BleProxyCommandMap[C]["result"]
224
- >;
128
+ /** Returns the connection that owns `address`, if it is still connected. */
129
+ getOwner(address: string): BleProxyConnection | undefined {
130
+ const entry = this.#owners.get(address);
131
+ if (entry && entry.owner.connected) return entry.owner;
132
+ return undefined;
225
133
  }
226
134
 
227
- /**
228
- * Send a binary frame to the BLE proxy client.
229
- */
230
- sendBinaryFrame(opcode: number, connectionHandle: number, payload: Uint8Array): void {
231
- if (!this.connected || !this.#client) {
232
- throw new Error("BLE proxy client not connected");
135
+ async startScan(args: StartScanArgs): Promise<void> {
136
+ this.#scanActive = true;
137
+ this.#scanArgs = args;
138
+ const sends = new Array<Promise<unknown>>();
139
+ for (const connection of this.#connections) {
140
+ if (connection.connected) {
141
+ this.#scanning.add(connection);
142
+ sends.push(
143
+ connection.sendCommand(BleProxyCommand.StartScan, args).catch(err => {
144
+ logger.warn(`[${connection.id}] Failed to start scan:`, err);
145
+ this.#markNotScanning(connection, "start scan failed");
146
+ }),
147
+ );
148
+ }
233
149
  }
234
- const frame = encodeBinaryFrame(opcode, connectionHandle, payload);
235
- this.#client.send(frame);
150
+ await Promise.all(sends);
236
151
  }
237
152
 
238
- #handleTextMessage(raw: string, handshakeTimer: Timer): void {
239
- let parsed: unknown;
240
- try {
241
- parsed = JSON.parse(raw);
242
- } catch {
243
- logger.warn("Received invalid JSON from BLE proxy client");
244
- return;
245
- }
246
-
247
- const msg = parsed as Record<string, unknown>;
248
-
249
- // Handle handshake
250
- if (!this.#handshakeComplete) {
251
- this.#handleHandshake(msg, handshakeTimer);
252
- return;
253
- }
254
-
255
- // Handle response to a pending command
256
- if ("id" in msg && "success" in msg) {
257
- this.#handleResponse(msg as unknown as ResponseMessage);
258
- return;
259
- }
260
-
261
- // Handle event
262
- if ("event" in msg && "data" in msg) {
263
- this.#handleEvent(msg as unknown as EventMessage);
264
- return;
153
+ async stopScan(): Promise<void> {
154
+ this.#scanActive = false;
155
+ const sends = new Array<Promise<unknown>>();
156
+ for (const connection of this.#connections) {
157
+ if (connection.connected) {
158
+ sends.push(
159
+ connection
160
+ .sendCommand(BleProxyCommand.StopScan)
161
+ .catch(err => logger.warn(`[${connection.id}] Failed to stop scan:`, err)),
162
+ );
163
+ }
265
164
  }
266
-
267
- logger.warn("Received unknown message from BLE proxy client:", msg);
165
+ this.#scanning.clear();
166
+ await Promise.all(sends);
268
167
  }
269
168
 
270
- #handleHandshake(msg: Record<string, unknown>, handshakeTimer: Timer): void {
271
- if (msg.type !== "hello") {
272
- logger.warn(`Expected hello message, got: ${JSON.stringify(msg)}`);
273
- this.#client?.close(4002, "Expected hello message");
274
- this.#cleanupClient();
275
- return;
276
- }
277
-
278
- const hello = msg as unknown as HelloMessage;
279
- handshakeTimer.stop();
280
-
281
- if (hello.version !== BLE_PROXY_PROTOCOL_VERSION) {
282
- logger.warn(
283
- `BLE proxy client version ${hello.version} not supported (server supports ${BLE_PROXY_PROTOCOL_VERSION})`,
284
- );
285
- this.#client?.send(
286
- JSON.stringify({
287
- type: "hello_response",
288
- version: BLE_PROXY_PROTOCOL_VERSION,
289
- error: "unsupported_version",
290
- message: `Server supports protocol version ${BLE_PROXY_PROTOCOL_VERSION}, client sent version ${hello.version}`,
291
- }),
292
- );
293
- this.#client?.close(4003, "Unsupported protocol version");
294
- this.#cleanupClient();
295
- return;
296
- }
297
-
298
- this.#handshakeComplete = true;
299
- this.#client?.send(
300
- JSON.stringify({
301
- type: "hello_response",
302
- version: BLE_PROXY_PROTOCOL_VERSION,
303
- }),
304
- );
305
- logger.info(`BLE proxy handshake complete (version ${BLE_PROXY_PROTOCOL_VERSION})`);
169
+ #onHandshakeCompleted(connection: BleProxyConnection): void {
306
170
  this.connectionEstablished.emit();
307
- }
308
-
309
- #handleResponse(msg: ResponseMessage): void {
310
- const pending = this.#pendingCommands.get(msg.id);
311
- if (!pending) {
312
- logger.warn(`Received response for unknown command id ${msg.id}`);
313
- return;
314
- }
315
- this.#pendingCommands.delete(msg.id);
316
-
317
- if (msg.success) {
318
- pending.resolver(msg.result);
319
- } else {
320
- pending.rejecter(new Error(`${msg.error}: ${msg.message}`));
171
+ if (this.#scanActive && this.#scanArgs) {
172
+ this.#scanning.add(connection);
173
+ connection.sendCommand(BleProxyCommand.StartScan, this.#scanArgs).catch(err => {
174
+ logger.warn(`[${connection.id}] Failed to sync scan to joining client:`, err);
175
+ this.#markNotScanning(connection, "start scan failed");
176
+ });
321
177
  }
322
178
  }
323
179
 
324
- #handleEvent(msg: EventMessage): void {
325
- this.eventReceived.emit(msg.event, msg.data);
180
+ #onConnectionEvent(connection: BleProxyConnection, event: string, data: Record<string, unknown>): void {
181
+ if (event === BleProxyEvent.DeviceDiscovered) {
182
+ this.#onDeviceDiscovered(connection, data as unknown as DeviceDiscoveredData);
183
+ } else if (event === BleProxyEvent.ScanStopped) {
184
+ this.#markNotScanning(connection, (data as { reason?: string }).reason ?? "unknown");
185
+ }
326
186
  }
327
187
 
328
- #handleBinaryMessage(data: Buffer): void {
329
- if (!this.#handshakeComplete) {
330
- logger.warn("Received binary frame before handshake");
188
+ /** Drop a connection from the scanning set; emit aggregate scanStopped once none remain. */
189
+ #markNotScanning(connection: BleProxyConnection, reason: string): void {
190
+ if (!this.#scanning.delete(connection)) {
331
191
  return;
332
192
  }
333
-
334
- try {
335
- const frame = decodeBinaryFrame(new Uint8Array(data));
336
- const head = Array.from(frame.payload.subarray(0, 8))
337
- .map(b => b.toString(16).padStart(2, "0"))
338
- .join("");
339
- logger.debug(
340
- `[FRAME] opcode=${frame.opcode} handle=${frame.connectionHandle} len=${frame.payload.length} head=${head}`,
341
- );
342
- this.binaryFrameReceived.emit(frame);
343
- } catch (err) {
344
- logger.warn("Failed to decode binary frame:", err);
193
+ if (this.#scanning.size === 0) {
194
+ this.scanStopped.emit(reason);
345
195
  }
346
196
  }
347
197
 
348
- #cleanupClient(): void {
349
- if (this.#client) {
350
- if (this.#client.readyState === WebSocket.OPEN) {
351
- this.#client.close();
198
+ #onDeviceDiscovered(connection: BleProxyConnection, data: DeviceDiscoveredData): void {
199
+ let entry = this.#owners.get(data.address);
200
+ if (!entry) {
201
+ entry = { owner: connection, seers: new Set([connection]) };
202
+ this.#owners.set(data.address, entry);
203
+ } else {
204
+ entry.seers.add(connection);
205
+ if (!entry.owner.connected) {
206
+ entry.owner = connection;
352
207
  }
353
- this.#client = undefined;
354
208
  }
355
- this.#handshakeComplete = false;
209
+ logger.debug(
210
+ `[${connection.id}] device_discovered ${data.address} rssi=${data.rssi ?? "n/a"} seers=${entry.seers.size} isOwner=${entry.owner === connection}`,
211
+ );
212
+ this.deviceDiscovered.emit(data, connection);
213
+ }
356
214
 
357
- // Reject all pending commands
358
- for (const [id, pending] of this.#pendingCommands) {
359
- pending.rejecter(new Error("BLE proxy client disconnected"));
360
- this.#pendingCommands.delete(id);
215
+ #onConnectionClosed(connection: BleProxyConnection): void {
216
+ this.#connections.delete(connection);
217
+ this.#markNotScanning(connection, "client disconnected");
218
+
219
+ for (const [address, entry] of this.#owners) {
220
+ entry.seers.delete(connection);
221
+ if (entry.owner === connection) {
222
+ let next: BleProxyConnection | undefined;
223
+ for (const seer of entry.seers) {
224
+ if (seer.connected) {
225
+ next = seer;
226
+ break;
227
+ }
228
+ }
229
+ if (next) {
230
+ entry.owner = next;
231
+ logger.info(`Reassigned ownership of ${address} to [${next.id}]`);
232
+ } else {
233
+ this.#owners.delete(address);
234
+ }
235
+ }
361
236
  }
362
237
  }
363
238
  }