@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,7 +5,6 @@
5
5
  */
6
6
  import { createPromise, Logger, Seconds, withTimeout } from "@matter/main";
7
7
  import { BleError, MatterBle } from "@matter/main/protocol";
8
- import { BleProxyCommand, BleProxyEvent } from "./BleProxyProtocol.js";
9
8
  const logger = Logger.get("ProxyBleClient");
10
9
  const SCAN_CONNECT_WAIT = Seconds(30);
11
10
  class ProxyBleClient {
@@ -13,17 +12,17 @@ class ProxyBleClient {
13
12
  #discoveredPeripherals = /* @__PURE__ */ new Map();
14
13
  #deviceDiscoveredCallback;
15
14
  #isScanning = false;
16
- #eventObserver = (event, data) => {
17
- if (event === BleProxyEvent.DeviceDiscovered) {
18
- this.#handleDeviceDiscovered(data);
19
- } else if (event === BleProxyEvent.ScanStopped) {
20
- logger.info(`Scan stopped by proxy client: ${data.reason ?? "unknown"}`);
21
- this.#isScanning = false;
22
- }
15
+ #deviceDiscoveredObserver = (data, _connection) => {
16
+ this.#handleDeviceDiscovered(data);
17
+ };
18
+ #scanStoppedObserver = (reason) => {
19
+ logger.info(`Scan stopped by proxy client: ${reason}`);
20
+ this.#isScanning = false;
23
21
  };
24
22
  constructor(handler) {
25
23
  this.#handler = handler;
26
- this.#handler.eventReceived.on(this.#eventObserver);
24
+ this.#handler.deviceDiscovered.on(this.#deviceDiscoveredObserver);
25
+ this.#handler.scanStopped.on(this.#scanStoppedObserver);
27
26
  }
28
27
  setDiscoveryCallback(callback) {
29
28
  this.#deviceDiscoveredCallback = callback;
@@ -51,27 +50,18 @@ class ProxyBleClient {
51
50
  }
52
51
  }
53
52
  logger.debug("Start BLE scanning via proxy ...");
54
- await this.#handler.sendCommand(BleProxyCommand.StartScan, {
55
- service_uuids: ["fff6"],
56
- allow_duplicates: false
57
- });
53
+ await this.#handler.startScan({ service_uuids: ["fff6"], allow_duplicates: false });
58
54
  this.#isScanning = true;
59
55
  }
60
56
  async stopScanning() {
61
- if (!this.#isScanning) {
62
- return;
63
- }
64
- if (!this.#handler.connected) {
65
- this.#isScanning = false;
66
- return;
67
- }
68
- logger.debug("Stop BLE scanning via proxy ...");
69
- await this.#handler.sendCommand(BleProxyCommand.StopScan);
70
57
  this.#isScanning = false;
58
+ logger.debug("Stop BLE scanning via proxy ...");
59
+ await this.#handler.stopScan();
71
60
  }
72
61
  close() {
73
62
  this.#isScanning = false;
74
- this.#handler.eventReceived.off(this.#eventObserver);
63
+ this.#handler.deviceDiscovered.off(this.#deviceDiscoveredObserver);
64
+ this.#handler.scanStopped.off(this.#scanStoppedObserver);
75
65
  }
76
66
  #handleDeviceDiscovered(data) {
77
67
  const { address, name, rssi, connectable, service_data } = data;
@@ -101,7 +91,9 @@ class ProxyBleClient {
101
91
  logger.debug(`Peripheral ${address} does not advertise valid Matter service data, ignoring`);
102
92
  return;
103
93
  }
104
- logger.debug(`Discovered device ${address} (${name ?? "unnamed"}) via proxy`);
94
+ logger.info(
95
+ `Discovered commissionable device ${address} (${name ?? "unnamed"}) rssi=${rssi ?? "n/a"} via proxy`
96
+ );
105
97
  this.#discoveredPeripherals.set(address, { peripheral, matterServiceData });
106
98
  this.#deviceDiscoveredCallback?.(peripheral, matterServiceData);
107
99
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/ProxyBleClient.ts"],
4
- "mappings": "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",
4
+ "mappings": "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",
5
5
  "names": []
6
6
  }
@@ -6,6 +6,7 @@
6
6
  /**
7
7
  * @matter-server/ble-proxy - BLE proxy for matter.js over WebSocket
8
8
  */
9
+ export * from "./BleProxyConnection.js";
9
10
  export * from "./BleProxyHandler.js";
10
11
  export * from "./BleProxyProtocol.js";
11
12
  export * from "./ProxyBle.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;GAEG;AAEH,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;GAEG;AAEH,cAAc,yBAAyB,CAAC;AACxC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC"}
package/dist/esm/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * Copyright 2025-2026 Open Home Foundation
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
+ export * from "./BleProxyConnection.js";
6
7
  export * from "./BleProxyHandler.js";
7
8
  export * from "./BleProxyProtocol.js";
8
9
  export * from "./ProxyBle.js";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/index.ts"],
4
- "mappings": "AAAA;AAAA;AAAA;AAAA;AAAA;AAUA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;",
4
+ "mappings": "AAAA;AAAA;AAAA;AAAA;AAAA;AAUA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;",
5
5
  "names": []
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matter-server/ble-proxy",
3
- "version": "0.7.2-alpha.0-20260602-cef59f5",
3
+ "version": "0.8.0",
4
4
  "description": "BLE proxy implementation for matter.js - proxies BLE operations over WebSocket",
5
5
  "homepage": "https://github.com/matter-js/matterjs-server",
6
6
  "bugs": {
@@ -29,8 +29,8 @@
29
29
  "noble-ble-proxy": "nacho-run src/example/noble-ble-proxy.ts"
30
30
  },
31
31
  "dependencies": {
32
- "@matter-server/ws-controller": "0.7.2-alpha.0-20260602-cef59f5",
33
- "@matter/main": "0.17.1-alpha.0-20260601-9386e30f9",
32
+ "@matter-server/ws-controller": "0.8.0",
33
+ "@matter/main": "0.17.1-alpha.0-20260602-24f28a953",
34
34
  "ws": "^8.21.0"
35
35
  },
36
36
  "devDependencies": {
@@ -38,7 +38,7 @@
38
38
  "@types/ws": "^8.18.1"
39
39
  },
40
40
  "optionalDependencies": {
41
- "@matter/nodejs-ble": "0.17.1-alpha.0-20260601-9386e30f9",
41
+ "@matter/nodejs-ble": "0.17.1-alpha.0-20260602-24f28a953",
42
42
  "dbus-next": "npm:@jellybrick/dbus-next@^0.10.3"
43
43
  },
44
44
  "engines": {
@@ -0,0 +1,282 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { createPromise, Logger, Observable, Seconds, Time, withTimeout } from "@matter/main";
8
+ import { Mark } from "@matter/main/protocol";
9
+ import { WebSocket } from "ws";
10
+ import {
11
+ BLE_PROXY_PROTOCOL_VERSION,
12
+ decodeBinaryFrame,
13
+ encodeBinaryFrame,
14
+ type BinaryFrame,
15
+ type BleProxyCommandMap,
16
+ type BleProxyCommandName,
17
+ type BleProxyEventName,
18
+ type CommandMessage,
19
+ type EventMessage,
20
+ type HelloMessage,
21
+ type ResponseMessage,
22
+ } from "./BleProxyProtocol.js";
23
+
24
+ const logger = Logger.get("BleProxyConnection");
25
+
26
+ const frameHead = (payload: Uint8Array): string =>
27
+ Array.from(payload.subarray(0, 8))
28
+ .map(b => b.toString(16).padStart(2, "0"))
29
+ .join("");
30
+
31
+ /** Counter for generating unique connection IDs. */
32
+ let connectionIdCounter = 0;
33
+
34
+ /**
35
+ * Generate a unique connection ID as a hex string, rolling over at 0xFFFF to keep it short.
36
+ * Same scheme as the controller WebSocket handler, prefixed `ble` so proxy sockets are
37
+ * distinguishable from the controller WebSocket connections in shared logs.
38
+ */
39
+ function generateConnectionId(): string {
40
+ const id = connectionIdCounter;
41
+ connectionIdCounter = (connectionIdCounter + 1) & 0xffff;
42
+ return `ble${id.toString(16)}`;
43
+ }
44
+
45
+ const HANDSHAKE_TIMEOUT = Seconds(10);
46
+ /**
47
+ * Default per-command timeout. Long enough to cover the slowest BLE
48
+ * commissioning ops (peripheral connect ≤ 30 s, service discovery on
49
+ * Android-side adapters can take several seconds), short enough that an
50
+ * unresponsive-but-still-connected proxy client doesn't hang commissioning
51
+ * indefinitely.
52
+ */
53
+ const COMMAND_TIMEOUT = Seconds(60);
54
+
55
+ /**
56
+ * One BLE proxy client socket. Owns that client's handshake state, pending
57
+ * command map, and command/event/binary-frame plumbing. The hub
58
+ * (`BleProxyHandler`) creates one per accepted WebSocket connection.
59
+ */
60
+ export class BleProxyConnection {
61
+ readonly #ws: WebSocket;
62
+ readonly #id = generateConnectionId();
63
+ #handshakeComplete = false;
64
+ #closedEmitted = false;
65
+ #pendingCommands = new Map<
66
+ number,
67
+ { resolver: (result: Record<string, unknown> | undefined) => void; rejecter: (reason?: unknown) => void }
68
+ >();
69
+ /** Command ID counter. Rolls over at 0xFFFF to stay in safe integer range. */
70
+ #nextCommandId = 0;
71
+
72
+ readonly binaryFrameReceived = new Observable<[frame: BinaryFrame]>();
73
+ readonly eventReceived = new Observable<[event: BleProxyEventName, data: Record<string, unknown>]>();
74
+ /** Emitted once when this connection completes the protocol handshake. */
75
+ readonly handshakeCompleted = new Observable<[]>();
76
+ /** Emitted once when this connection is torn down (close, error, or handshake failure). */
77
+ readonly closed = new Observable<[]>();
78
+
79
+ constructor(ws: WebSocket) {
80
+ this.#ws = ws;
81
+ logger.info(`[${this.#id}] BLE proxy connection established, waiting for handshake`);
82
+
83
+ const handshakeTimer = Time.getTimer("BLE proxy handshake timeout", HANDSHAKE_TIMEOUT, () => {
84
+ if (!this.#handshakeComplete) {
85
+ logger.warn(`[${this.#id}] Handshake timeout - closing connection`);
86
+ ws.close(4001, "Handshake timeout");
87
+ this.#cleanup();
88
+ }
89
+ }).start();
90
+
91
+ ws.on("message", (data, isBinary) => {
92
+ if (isBinary) {
93
+ this.#handleBinaryMessage(data as Buffer);
94
+ } else {
95
+ this.#handleTextMessage(data.toString(), handshakeTimer);
96
+ }
97
+ });
98
+
99
+ ws.on("close", () => {
100
+ handshakeTimer.stop();
101
+ logger.info(`[${this.#id}] BLE proxy connection closed`);
102
+ this.#cleanup();
103
+ });
104
+
105
+ ws.on("error", err => {
106
+ logger.error(`[${this.#id}] BLE proxy WebSocket error`, err);
107
+ handshakeTimer.stop();
108
+ this.#cleanup();
109
+ });
110
+ }
111
+
112
+ get id(): string {
113
+ return this.#id;
114
+ }
115
+
116
+ get connected(): boolean {
117
+ return this.#handshakeComplete && this.#ws.readyState === WebSocket.OPEN;
118
+ }
119
+
120
+ /**
121
+ * Send a typed command to this client and wait for the response.
122
+ * Rejects if the connection is not ready or the client disconnects first.
123
+ */
124
+ async sendCommand<C extends BleProxyCommandName>(
125
+ command: C,
126
+ ...rest: BleProxyCommandMap[C]["args"] extends undefined ? [] : [args: BleProxyCommandMap[C]["args"]]
127
+ ): Promise<BleProxyCommandMap[C]["result"]> {
128
+ if (!this.connected) {
129
+ throw new Error("BLE proxy client not connected");
130
+ }
131
+
132
+ const args = rest[0];
133
+ const id = this.#nextCommandId;
134
+ this.#nextCommandId = (this.#nextCommandId + 1) & 0xffff;
135
+ const message: CommandMessage = { id, command };
136
+ if (args !== undefined) {
137
+ message.args = args as Record<string, unknown>;
138
+ }
139
+
140
+ const { promise, resolver, rejecter } = createPromise<Record<string, unknown> | undefined>();
141
+ this.#pendingCommands.set(id, { resolver, rejecter });
142
+
143
+ this.#ws.send(JSON.stringify(message), err => {
144
+ if (err) {
145
+ this.#pendingCommands.delete(id);
146
+ rejecter(new Error(`Failed to send command ${command}: ${err.message}`));
147
+ }
148
+ });
149
+
150
+ return withTimeout(COMMAND_TIMEOUT, promise, () => this.#pendingCommands.delete(id)) as Promise<
151
+ BleProxyCommandMap[C]["result"]
152
+ >;
153
+ }
154
+
155
+ sendBinaryFrame(opcode: number, connectionHandle: number, payload: Uint8Array): void {
156
+ if (!this.connected) {
157
+ throw new Error("BLE proxy client not connected");
158
+ }
159
+ logger.debug(
160
+ `[${this.#id}] [FRAME] ${Mark.OUTBOUND} opcode=${opcode} handle=${connectionHandle} len=${payload.length} head=${frameHead(payload)}`,
161
+ );
162
+ this.#ws.send(encodeBinaryFrame(opcode, connectionHandle, payload));
163
+ }
164
+
165
+ #handleTextMessage(raw: string, handshakeTimer: { stop: () => void }): void {
166
+ let parsed: unknown;
167
+ try {
168
+ parsed = JSON.parse(raw);
169
+ } catch {
170
+ logger.warn(`[${this.#id}] Received invalid JSON from BLE proxy client`);
171
+ return;
172
+ }
173
+
174
+ const msg = parsed as Record<string, unknown>;
175
+
176
+ if (!this.#handshakeComplete) {
177
+ this.#handleHandshake(msg, handshakeTimer);
178
+ return;
179
+ }
180
+
181
+ if ("id" in msg && "success" in msg) {
182
+ this.#handleResponse(msg as unknown as ResponseMessage);
183
+ return;
184
+ }
185
+
186
+ if ("event" in msg && "data" in msg) {
187
+ const evt = msg as unknown as EventMessage;
188
+ this.eventReceived.emit(evt.event, evt.data);
189
+ return;
190
+ }
191
+
192
+ logger.warn(`[${this.#id}] Received unknown message from BLE proxy client:`, msg);
193
+ }
194
+
195
+ #handleHandshake(msg: Record<string, unknown>, handshakeTimer: { stop: () => void }): void {
196
+ if (msg.type !== "hello") {
197
+ logger.warn(`[${this.#id}] Expected hello message, got: ${JSON.stringify(msg)}`);
198
+ this.#ws.close(4002, "Expected hello message");
199
+ this.#cleanup();
200
+ return;
201
+ }
202
+
203
+ const hello = msg as unknown as HelloMessage;
204
+ handshakeTimer.stop();
205
+
206
+ if (hello.version !== BLE_PROXY_PROTOCOL_VERSION) {
207
+ logger.warn(
208
+ `[${this.#id}] BLE proxy client version ${hello.version} not supported (server supports ${BLE_PROXY_PROTOCOL_VERSION})`,
209
+ );
210
+ this.#ws.send(
211
+ JSON.stringify({
212
+ type: "hello_response",
213
+ version: BLE_PROXY_PROTOCOL_VERSION,
214
+ error: "unsupported_version",
215
+ message: `Server supports protocol version ${BLE_PROXY_PROTOCOL_VERSION}, client sent version ${hello.version}`,
216
+ }),
217
+ );
218
+ this.#ws.close(4003, "Unsupported protocol version");
219
+ this.#cleanup();
220
+ return;
221
+ }
222
+
223
+ this.#handshakeComplete = true;
224
+ this.#ws.send(JSON.stringify({ type: "hello_response", version: BLE_PROXY_PROTOCOL_VERSION }));
225
+ logger.info(`[${this.#id}] BLE proxy handshake complete (version ${BLE_PROXY_PROTOCOL_VERSION})`);
226
+ this.handshakeCompleted.emit();
227
+ }
228
+
229
+ #handleResponse(msg: ResponseMessage): void {
230
+ const pending = this.#pendingCommands.get(msg.id);
231
+ if (!pending) {
232
+ logger.warn(`[${this.#id}] Received response for unknown command id ${msg.id}`);
233
+ return;
234
+ }
235
+ this.#pendingCommands.delete(msg.id);
236
+
237
+ if (msg.success) {
238
+ pending.resolver(msg.result);
239
+ } else {
240
+ pending.rejecter(new Error(`${msg.error}: ${msg.message}`));
241
+ }
242
+ }
243
+
244
+ #handleBinaryMessage(data: Buffer): void {
245
+ if (!this.#handshakeComplete) {
246
+ logger.warn(`[${this.#id}] Received binary frame before handshake`);
247
+ return;
248
+ }
249
+
250
+ try {
251
+ const frame = decodeBinaryFrame(new Uint8Array(data));
252
+ logger.debug(
253
+ `[${this.#id}] [FRAME] ${Mark.INBOUND} opcode=${frame.opcode} handle=${frame.connectionHandle} len=${frame.payload.length} head=${frameHead(frame.payload)}`,
254
+ );
255
+ this.binaryFrameReceived.emit(frame);
256
+ } catch (err) {
257
+ logger.warn(`[${this.#id}] Failed to decode binary frame:`, err);
258
+ }
259
+ }
260
+
261
+ /** Close the socket if open, reject all pending commands, emit `closed` once. */
262
+ close(): void {
263
+ if (this.#ws.readyState === WebSocket.OPEN) {
264
+ this.#ws.close();
265
+ }
266
+ this.#cleanup();
267
+ }
268
+
269
+ #cleanup(): void {
270
+ this.#handshakeComplete = false;
271
+
272
+ for (const [id, pending] of this.#pendingCommands) {
273
+ pending.rejecter(new Error("BLE proxy client disconnected"));
274
+ this.#pendingCommands.delete(id);
275
+ }
276
+
277
+ if (!this.#closedEmitted) {
278
+ this.#closedEmitted = true;
279
+ this.closed.emit();
280
+ }
281
+ }
282
+ }