@matter-server/ble-proxy 0.0.1

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.
Files changed (50) hide show
  1. package/README.md +116 -0
  2. package/dist/esm/BleProxyHandler.d.ts +39 -0
  3. package/dist/esm/BleProxyHandler.d.ts.map +1 -0
  4. package/dist/esm/BleProxyHandler.js +256 -0
  5. package/dist/esm/BleProxyHandler.js.map +6 -0
  6. package/dist/esm/BleProxyProtocol.d.ts +230 -0
  7. package/dist/esm/BleProxyProtocol.d.ts.map +1 -0
  8. package/dist/esm/BleProxyProtocol.js +82 -0
  9. package/dist/esm/BleProxyProtocol.js.map +6 -0
  10. package/dist/esm/ProxyBle.d.ts +24 -0
  11. package/dist/esm/ProxyBle.d.ts.map +1 -0
  12. package/dist/esm/ProxyBle.js +50 -0
  13. package/dist/esm/ProxyBle.js.map +6 -0
  14. package/dist/esm/ProxyBleChannel.d.ts +38 -0
  15. package/dist/esm/ProxyBleChannel.d.ts.map +1 -0
  16. package/dist/esm/ProxyBleChannel.js +314 -0
  17. package/dist/esm/ProxyBleChannel.js.map +6 -0
  18. package/dist/esm/ProxyBleClient.d.ts +32 -0
  19. package/dist/esm/ProxyBleClient.d.ts.map +1 -0
  20. package/dist/esm/ProxyBleClient.js +100 -0
  21. package/dist/esm/ProxyBleClient.js.map +6 -0
  22. package/dist/esm/ProxyBleScanner.d.ts +27 -0
  23. package/dist/esm/ProxyBleScanner.d.ts.map +1 -0
  24. package/dist/esm/ProxyBleScanner.js +23 -0
  25. package/dist/esm/ProxyBleScanner.js.map +6 -0
  26. package/dist/esm/example/NobleBleProxyClient.d.ts +12 -0
  27. package/dist/esm/example/NobleBleProxyClient.d.ts.map +1 -0
  28. package/dist/esm/example/NobleBleProxyClient.js +520 -0
  29. package/dist/esm/example/NobleBleProxyClient.js.map +6 -0
  30. package/dist/esm/example/noble-ble-proxy.d.ts +7 -0
  31. package/dist/esm/example/noble-ble-proxy.d.ts.map +1 -0
  32. package/dist/esm/example/noble-ble-proxy.js +45 -0
  33. package/dist/esm/example/noble-ble-proxy.js.map +6 -0
  34. package/dist/esm/index.d.ts +15 -0
  35. package/dist/esm/index.d.ts.map +1 -0
  36. package/dist/esm/index.js +12 -0
  37. package/dist/esm/index.js.map +6 -0
  38. package/dist/esm/package.json +3 -0
  39. package/package.json +46 -0
  40. package/src/BleProxyHandler.ts +345 -0
  41. package/src/BleProxyProtocol.ts +287 -0
  42. package/src/ProxyBle.ts +64 -0
  43. package/src/ProxyBleChannel.ts +407 -0
  44. package/src/ProxyBleClient.ts +136 -0
  45. package/src/ProxyBleScanner.ts +38 -0
  46. package/src/example/NobleBleProxyClient.ts +679 -0
  47. package/src/example/noble-ble-proxy.ts +62 -0
  48. package/src/index.ts +16 -0
  49. package/src/tsconfig.json +15 -0
  50. package/src/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { type Environment, ImplementationError, type Transport } from "@matter/main";
8
+ import { Ble, type BlePeripheralInterface, type Scanner } from "@matter/main/protocol";
9
+ import type { BleProxyHandler } from "./BleProxyHandler.js";
10
+ import { ProxyBleCentralInterface } from "./ProxyBleChannel.js";
11
+ import { ProxyBleClient } from "./ProxyBleClient.js";
12
+ import { ProxyBleScanner } from "./ProxyBleScanner.js";
13
+
14
+ /**
15
+ * BLE implementation that proxies all operations over a WebSocket connection.
16
+ * Replaces NodeJsBle for environments where BLE hardware is accessed through
17
+ * a remote proxy client (e.g. Home Assistant with ESPHome BLE proxies).
18
+ *
19
+ * Only central (client) mode is supported. Peripheral mode throws.
20
+ */
21
+ export class ProxyBle extends Ble {
22
+ readonly #handler: BleProxyHandler;
23
+ #proxyBleClient?: ProxyBleClient;
24
+ #bleScanner?: ProxyBleScanner;
25
+ #bleCentralInterface?: ProxyBleCentralInterface;
26
+ #closed = false;
27
+
28
+ constructor(handler: BleProxyHandler, environment?: Environment) {
29
+ super();
30
+ this.#handler = handler;
31
+ // Register with the runtime so shutdown drives scanner.close() and clears pending
32
+ // discovery waiters. Mirrors @matter/nodejs-ble NobleBleClient's runtime registration.
33
+ environment?.runtime.add(this);
34
+ }
35
+
36
+ get peripheralInterface(): BlePeripheralInterface {
37
+ throw new ImplementationError("BLE Proxy only supports central mode, not peripheral");
38
+ }
39
+
40
+ get centralInterface(): Transport {
41
+ if (!this.#bleCentralInterface) {
42
+ this.#bleCentralInterface = new ProxyBleCentralInterface(this.scanner as ProxyBleScanner, this.#handler);
43
+ }
44
+ return this.#bleCentralInterface;
45
+ }
46
+
47
+ get scanner(): Scanner {
48
+ if (!this.#bleScanner) {
49
+ if (!this.#proxyBleClient) {
50
+ this.#proxyBleClient = new ProxyBleClient(this.#handler);
51
+ }
52
+ this.#bleScanner = new ProxyBleScanner(this.#proxyBleClient);
53
+ }
54
+ return this.#bleScanner;
55
+ }
56
+
57
+ async close(): Promise<void> {
58
+ if (this.#closed) return;
59
+ this.#closed = true;
60
+ await this.#bleCentralInterface?.close();
61
+ // scanner.close() also closes the underlying ProxyBleClient.
62
+ await this.#bleScanner?.close();
63
+ }
64
+ }
@@ -0,0 +1,407 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ type Bytes,
9
+ type Channel,
10
+ ChannelType,
11
+ createPromise,
12
+ InternalError,
13
+ Logger,
14
+ NetworkError,
15
+ ServerAddress,
16
+ Time,
17
+ type Transport,
18
+ } from "@matter/main";
19
+ import { BleChannel, BleError, BtpCodec, BtpFlowError, BtpSessionHandler, MatterBle } from "@matter/main/protocol";
20
+ import type { BleProxyHandler } from "./BleProxyHandler.js";
21
+ import { BinaryFrameOpcode, BleProxyCommand, BleProxyEvent } from "./BleProxyProtocol.js";
22
+ import type { ProxyBleScanner } from "./ProxyBleScanner.js";
23
+
24
+ const logger = Logger.get("ProxyBleChannel");
25
+
26
+ /** BTP handshake response identification bytes (Matter spec 4.18.2.5.6) */
27
+ const BTP_HANDSHAKE_RESPONSE_OPCODE_1 = 0x65;
28
+ const BTP_HANDSHAKE_RESPONSE_OPCODE_2 = 0x6c;
29
+ const BTP_HANDSHAKE_RESPONSE_LENGTH = 6;
30
+
31
+ /**
32
+ * Normalize any UUID form sent by a proxy client to the canonical dashed-uppercase form used by
33
+ * Matter's MatterBle constants. Different BLE proxy clients deliver different formats:
34
+ * - noble: 32 lowercase hex chars, no dashes ("18ee2ef5263d4559959f4f9c429f9d11")
35
+ * - ESPHome / generic: dashed form, either case ("18EE2EF5-263D-4559-959F-4F9C429F9D11")
36
+ * - 16-/32-bit short form ("fff6", "FFF6", "0000fff6") for standard service UUIDs
37
+ *
38
+ * All three round-trip to a comparable canonical string so command handlers stay format-agnostic.
39
+ */
40
+ function toCanonicalUuid(uuid: string): string {
41
+ const upper = uuid.toUpperCase();
42
+ if (upper.length === 32) {
43
+ return [
44
+ upper.substring(0, 8),
45
+ upper.substring(8, 12),
46
+ upper.substring(12, 16),
47
+ upper.substring(16, 20),
48
+ upper.substring(20, 32),
49
+ ].join("-");
50
+ }
51
+ return upper;
52
+ }
53
+
54
+ /**
55
+ * ConnectionlessTransport implementation that opens BLE channels through the proxy WebSocket.
56
+ * Replaces NobleBleCentralInterface from @matter/nodejs-ble.
57
+ */
58
+ export class ProxyBleCentralInterface implements Transport {
59
+ readonly #bleScanner: ProxyBleScanner;
60
+ readonly #handler: BleProxyHandler;
61
+ #onMatterMessageListener: ((socket: Channel<Bytes>, data: Bytes) => void) | undefined;
62
+ #closed = false;
63
+
64
+ constructor(bleScanner: ProxyBleScanner, handler: BleProxyHandler) {
65
+ this.#bleScanner = bleScanner;
66
+ this.#handler = handler;
67
+ }
68
+
69
+ async openChannel(address: ServerAddress): Promise<Channel<Bytes>> {
70
+ if (this.#closed) {
71
+ throw new NetworkError("Network interface is closed");
72
+ }
73
+ if (!ServerAddress.isBle(address)) {
74
+ throw new InternalError(`Unsupported address type for BLE channel.`);
75
+ }
76
+ if (this.#onMatterMessageListener === undefined) {
77
+ throw new InternalError("Network Interface was not added to the system yet.");
78
+ }
79
+
80
+ const { peripheralAddress } = address;
81
+ const { hasAdditionalAdvertisementData } = this.#bleScanner.getDiscoveredDevice(peripheralAddress);
82
+
83
+ if (!this.#handler.connected) {
84
+ throw new BleError("BLE proxy client not connected");
85
+ }
86
+
87
+ logger.debug(`Connecting to peripheral ${peripheralAddress} via proxy`);
88
+
89
+ // 1. Connect
90
+ const { connection_handle, mtu: peripheralMtu } = await this.#handler.sendCommand(BleProxyCommand.Connect, {
91
+ address: peripheralAddress,
92
+ });
93
+
94
+ let mtu = peripheralMtu ?? 0;
95
+ if (mtu > MatterBle.MAXIMUM_BTP_MTU) {
96
+ mtu = MatterBle.MAXIMUM_BTP_MTU;
97
+ }
98
+ logger.debug(`Connected to ${peripheralAddress}, handle=${connection_handle}, mtu=${mtu}`);
99
+
100
+ try {
101
+ // 2. Discover services
102
+ const { services } = await this.#handler.sendCommand(BleProxyCommand.DiscoverServices, {
103
+ connection_handle,
104
+ });
105
+
106
+ const matterService = services.find(s => MatterBle.isServiceUuid(s.uuid));
107
+ if (!matterService) {
108
+ throw new BleError(`Peripheral ${peripheralAddress} does not have Matter BLE service`);
109
+ }
110
+
111
+ // 3. Discover characteristics
112
+ const { characteristics } = await this.#handler.sendCommand(BleProxyCommand.DiscoverCharacteristics, {
113
+ connection_handle,
114
+ service_uuid: matterService.uuid,
115
+ });
116
+
117
+ let c1Uuid: string | undefined;
118
+ let c2Uuid: string | undefined;
119
+ let c3Uuid: string | undefined;
120
+
121
+ for (const char of characteristics) {
122
+ const canonical = toCanonicalUuid(char.uuid);
123
+ if (canonical === MatterBle.C1_CHARACTERISTIC_UUID) {
124
+ c1Uuid = char.uuid;
125
+ } else if (canonical === MatterBle.C2_CHARACTERISTIC_UUID) {
126
+ c2Uuid = char.uuid;
127
+ } else if (canonical === MatterBle.C3_CHARACTERISTIC_UUID) {
128
+ c3Uuid = char.uuid;
129
+ }
130
+ }
131
+
132
+ if (!c1Uuid || !c2Uuid) {
133
+ throw new BleError(`Peripheral ${peripheralAddress} missing required Matter characteristics (C1/C2)`);
134
+ }
135
+
136
+ // 4. Read C3 if present and has additional data
137
+ if (c3Uuid && hasAdditionalAdvertisementData) {
138
+ logger.debug(`Reading additional commissioning data from C3`);
139
+ await this.#handler.sendCommand(BleProxyCommand.ReadCharacteristic, {
140
+ connection_handle,
141
+ characteristic_uuid: c3Uuid,
142
+ });
143
+ // Additional commissioning data read but not used directly by the proxy -
144
+ // it's handled by matter.js commissioning flow internally
145
+ }
146
+
147
+ // 5. Send BTP handshake request on C1.
148
+ // Matter BTP spec: writes to C1 use ATT Write Request (with response). C1 typically
149
+ // does not advertise writeWithoutResponse, so a writeWithoutResponse is silently
150
+ // dropped or rejected by the peripheral and the BTP handshake response never comes.
151
+ const btpHandshakeRequest = BtpCodec.encodeBtpHandshakeRequest({
152
+ versions: MatterBle.BTP_SUPPORTED_VERSIONS,
153
+ attMtu: mtu,
154
+ clientWindowSize: MatterBle.BTP_MAXIMUM_WINDOW_SIZE,
155
+ });
156
+ logger.debug(`Sending BTP handshake request on C1`);
157
+ await this.#handler.sendCommand(BleProxyCommand.WriteCharacteristic, {
158
+ connection_handle,
159
+ characteristic_uuid: c1Uuid,
160
+ value: Buffer.from(btpHandshakeRequest as ArrayBuffer).toString("base64"),
161
+ response: true,
162
+ });
163
+
164
+ // 6. Subscribe to C2 for notifications
165
+ await this.#handler.sendCommand(BleProxyCommand.SubscribeCharacteristic, {
166
+ connection_handle,
167
+ characteristic_uuid: c2Uuid,
168
+ });
169
+
170
+ // 7. Wait for BTP handshake response via binary frame
171
+ const {
172
+ promise: handshakePromise,
173
+ resolver: handshakeResolver,
174
+ rejecter: handshakeRejecter,
175
+ } = createPromise<Uint8Array>();
176
+
177
+ const btpHandshakeTimeout = Time.getTimer(
178
+ "BLE proxy handshake timeout",
179
+ MatterBle.BTP_CONN_RSP_TIMEOUT,
180
+ () => {
181
+ handshakeRejecter(new BleError(`BTP handshake response not received from ${peripheralAddress}`));
182
+ },
183
+ ).start();
184
+
185
+ const handshakeObserver = (frame: { connectionHandle: number; opcode: number; payload: Uint8Array }) => {
186
+ if (frame.connectionHandle === connection_handle && frame.opcode === BinaryFrameOpcode.Notification) {
187
+ const data = new Uint8Array(frame.payload);
188
+ if (
189
+ data[0] === BTP_HANDSHAKE_RESPONSE_OPCODE_1 &&
190
+ data[1] === BTP_HANDSHAKE_RESPONSE_OPCODE_2 &&
191
+ data.length === BTP_HANDSHAKE_RESPONSE_LENGTH
192
+ ) {
193
+ btpHandshakeTimeout.stop();
194
+ handshakeResolver(data);
195
+ }
196
+ }
197
+ };
198
+ this.#handler.binaryFrameReceived.on(handshakeObserver);
199
+
200
+ let handshakeResponse: Uint8Array;
201
+ try {
202
+ handshakeResponse = await handshakePromise;
203
+ } finally {
204
+ this.#handler.binaryFrameReceived.off(handshakeObserver);
205
+ btpHandshakeTimeout.stop();
206
+ }
207
+
208
+ // 8. Create BTP session
209
+ // Use a ref object so closures can access the channel after it's created
210
+ const onMatterMessageListener = this.#onMatterMessageListener;
211
+ const channelRef: { channel?: ProxyBleChannel } = {};
212
+
213
+ const btpSession = await BtpSessionHandler.createAsCentral(
214
+ handshakeResponse,
215
+ // Write callback: send binary frame to proxy client
216
+ async (data: Bytes) => {
217
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
218
+ this.#handler.sendBinaryFrame(BinaryFrameOpcode.WriteData, connection_handle, bytes);
219
+ },
220
+ // Disconnect callback
221
+ async () => {
222
+ if (!channelRef.channel?.connected) return;
223
+ logger.debug(`Disconnecting from ${peripheralAddress} via proxy`);
224
+ try {
225
+ await this.#handler.sendCommand(BleProxyCommand.Disconnect, { connection_handle });
226
+ } catch (error) {
227
+ logger.debug(
228
+ `Peripheral ${peripheralAddress}: Error sending Disconnect to proxy client`,
229
+ error,
230
+ );
231
+ }
232
+ },
233
+ // Matter message callback
234
+ async (data: Bytes) => {
235
+ if (channelRef.channel) {
236
+ channelRef.channel.pushMessage(data);
237
+ onMatterMessageListener(channelRef.channel, data);
238
+ }
239
+ },
240
+ );
241
+
242
+ // 9. Wire up binary frame notifications to BTP session
243
+ const binaryObserver = (frame: { connectionHandle: number; opcode: number; payload: Uint8Array }) => {
244
+ if (frame.connectionHandle === connection_handle && frame.opcode === BinaryFrameOpcode.Notification) {
245
+ btpSession
246
+ .handleIncomingBleData(new Uint8Array(frame.payload))
247
+ .catch(error =>
248
+ logger.warn(`Peripheral ${peripheralAddress}: Error handling incoming BLE data`, error),
249
+ );
250
+ }
251
+ };
252
+ this.#handler.binaryFrameReceived.on(binaryObserver);
253
+
254
+ // 10. Handle unexpected disconnects from proxy client
255
+ const eventObserver = (event: string, data: Record<string, unknown>) => {
256
+ if (
257
+ event === BleProxyEvent.Disconnected &&
258
+ typeof data.connection_handle === "number" &&
259
+ data.connection_handle === connection_handle
260
+ ) {
261
+ logger.info(`Peripheral ${peripheralAddress} disconnected unexpectedly`);
262
+ channelRef.channel?.markDisconnected();
263
+ channelRef.channel
264
+ ?.close()
265
+ .catch(error => logger.debug(`Peripheral ${peripheralAddress}: Error closing channel`, error));
266
+ }
267
+ };
268
+ this.#handler.eventReceived.on(eventObserver);
269
+
270
+ // Cleanup function to remove observers when channel closes
271
+ const cleanupObservers = () => {
272
+ this.#handler.binaryFrameReceived.off(binaryObserver);
273
+ this.#handler.eventReceived.off(eventObserver);
274
+ };
275
+
276
+ const proxyChannel = new ProxyBleChannel(peripheralAddress, btpSession, cleanupObservers);
277
+ channelRef.channel = proxyChannel;
278
+ return proxyChannel;
279
+ } catch (error) {
280
+ // Clean up on failure — best-effort tear-down of the peripheral on the proxy side
281
+ try {
282
+ await this.#handler.sendCommand(BleProxyCommand.Disconnect, { connection_handle });
283
+ } catch (cleanupError) {
284
+ logger.debug(`Peripheral ${peripheralAddress}: Error during connect-failure cleanup`, cleanupError);
285
+ }
286
+ throw error;
287
+ }
288
+ }
289
+
290
+ onData(listener: (socket: Channel<Bytes>, data: Bytes) => void): Transport.Listener {
291
+ this.#onMatterMessageListener = listener;
292
+ return {
293
+ close: async () => await this.close(),
294
+ };
295
+ }
296
+
297
+ async close() {
298
+ this.#closed = true;
299
+ }
300
+
301
+ supports(type: ChannelType, _address?: string) {
302
+ return type === ChannelType.BLE;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * BLE channel that communicates through the proxy WebSocket.
308
+ * Replaces NobleBleChannel from @matter/nodejs-ble.
309
+ */
310
+ export class ProxyBleChannel extends BleChannel<Bytes> {
311
+ #connected = true;
312
+ readonly #peripheralAddress: string;
313
+ readonly #btpSession: BtpSessionHandler;
314
+ readonly #cleanupObservers: () => void;
315
+ readonly #onBtpSessionClosed: () => void;
316
+ readonly #closeListeners = new Set<() => void>();
317
+ #iteratorQueue = new Array<Bytes>();
318
+ #iteratorWaiter?: (value: IteratorResult<Bytes>) => void;
319
+ #iteratorDone = false;
320
+
321
+ constructor(peripheralAddress: string, btpSession: BtpSessionHandler, cleanupObservers: () => void) {
322
+ super();
323
+ this.#peripheralAddress = peripheralAddress;
324
+ this.#btpSession = btpSession;
325
+ this.#cleanupObservers = cleanupObservers;
326
+ this.#onBtpSessionClosed = () => this.emitClosed();
327
+ btpSession.closed.on(this.#onBtpSessionClosed);
328
+ }
329
+
330
+ get connected() {
331
+ return this.#connected;
332
+ }
333
+
334
+ markDisconnected() {
335
+ this.#connected = false;
336
+ }
337
+
338
+ pushMessage(data: Bytes): void {
339
+ if (this.#iteratorWaiter) {
340
+ const resolve = this.#iteratorWaiter;
341
+ this.#iteratorWaiter = undefined;
342
+ resolve({ value: data, done: false });
343
+ } else if (!this.#iteratorDone) {
344
+ this.#iteratorQueue.push(data);
345
+ }
346
+ }
347
+
348
+ onClose(listener: () => void): Transport.Listener {
349
+ this.#closeListeners.add(listener);
350
+ return {
351
+ close: async () => {
352
+ this.#closeListeners.delete(listener);
353
+ },
354
+ };
355
+ }
356
+
357
+ [Symbol.asyncIterator](): AsyncIterator<Bytes> {
358
+ return {
359
+ next: () => {
360
+ if (this.#iteratorQueue.length > 0) {
361
+ return Promise.resolve({ value: this.#iteratorQueue.shift()!, done: false });
362
+ }
363
+ if (this.#iteratorDone || !this.#connected) {
364
+ return Promise.resolve({ value: undefined as unknown as Bytes, done: true });
365
+ }
366
+ return new Promise<IteratorResult<Bytes>>(resolve => {
367
+ this.#iteratorWaiter = resolve;
368
+ });
369
+ },
370
+ };
371
+ }
372
+
373
+ #terminateIterator(): void {
374
+ if (!this.#iteratorDone) {
375
+ this.#iteratorDone = true;
376
+ this.#iteratorWaiter?.({ value: undefined as unknown as Bytes, done: true });
377
+ this.#iteratorWaiter = undefined;
378
+ }
379
+ }
380
+
381
+ async send(data: Bytes) {
382
+ if (!this.#connected) {
383
+ logger.debug(`Cannot send data - not connected to ${this.#peripheralAddress}`);
384
+ return;
385
+ }
386
+ if (this.#btpSession === undefined) {
387
+ throw new BtpFlowError(`Cannot send data, no BTP session initialized`);
388
+ }
389
+ await this.#btpSession.sendMatterMessage(data);
390
+ }
391
+
392
+ get name() {
393
+ return `ble-proxy://${this.#peripheralAddress}`;
394
+ }
395
+
396
+ async close() {
397
+ this.#connected = false;
398
+ this.#cleanupObservers();
399
+ this.#terminateIterator();
400
+ for (const listener of this.#closeListeners) {
401
+ listener();
402
+ }
403
+ this.#btpSession.closed.off(this.#onBtpSessionClosed);
404
+ await this.#btpSession.close();
405
+ this.emitClosed();
406
+ }
407
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { Logger } from "@matter/main";
8
+ import { MatterBle } from "@matter/main/protocol";
9
+ import type { BleProxyHandler } from "./BleProxyHandler.js";
10
+ import { BleProxyCommand, BleProxyEvent, type DeviceDiscoveredData } from "./BleProxyProtocol.js";
11
+
12
+ const logger = Logger.get("ProxyBleClient");
13
+
14
+ /**
15
+ * Represents a BLE peripheral discovered through the proxy.
16
+ * Replaces Noble's Peripheral type - stores the data received from the proxy client.
17
+ */
18
+ export interface ProxyPeripheral {
19
+ address: string;
20
+ name?: string;
21
+ rssi?: number;
22
+ connectable: boolean;
23
+ serviceData: Map<string, Uint8Array>;
24
+ mtu?: number;
25
+ }
26
+
27
+ /**
28
+ * BLE client that communicates over the BLE proxy WebSocket protocol.
29
+ * Replaces NobleBleClient - instead of using Noble directly, sends scan commands
30
+ * to the proxy client and receives device_discovered events.
31
+ */
32
+ export class ProxyBleClient {
33
+ readonly #handler: BleProxyHandler;
34
+ readonly #discoveredPeripherals = new Map<string, { peripheral: ProxyPeripheral; matterServiceData: Uint8Array }>();
35
+ #deviceDiscoveredCallback: ((peripheral: ProxyPeripheral, matterServiceData: Uint8Array) => void) | undefined;
36
+ #isScanning = false;
37
+
38
+ readonly #eventObserver = (event: string, data: Record<string, unknown>) => {
39
+ if (event === BleProxyEvent.DeviceDiscovered) {
40
+ this.#handleDeviceDiscovered(data as unknown as DeviceDiscoveredData);
41
+ } else if (event === BleProxyEvent.ScanStopped) {
42
+ logger.info(`Scan stopped by proxy client: ${(data as { reason?: string }).reason ?? "unknown"}`);
43
+ this.#isScanning = false;
44
+ }
45
+ };
46
+
47
+ constructor(handler: BleProxyHandler) {
48
+ this.#handler = handler;
49
+ this.#handler.eventReceived.on(this.#eventObserver);
50
+ }
51
+
52
+ setDiscoveryCallback(callback: (peripheral: ProxyPeripheral, matterServiceData: Uint8Array) => void): void {
53
+ this.#deviceDiscoveredCallback = callback;
54
+ for (const { peripheral, matterServiceData } of this.#discoveredPeripherals.values()) {
55
+ this.#deviceDiscoveredCallback(peripheral, matterServiceData);
56
+ }
57
+ }
58
+
59
+ async startScanning(): Promise<void> {
60
+ if (this.#isScanning) {
61
+ return;
62
+ }
63
+
64
+ if (!this.#handler.connected) {
65
+ logger.info("BLE proxy not connected, deferring scan start");
66
+ return;
67
+ }
68
+
69
+ logger.debug("Start BLE scanning via proxy ...");
70
+ await this.#handler.sendCommand(BleProxyCommand.StartScan, {
71
+ service_uuids: ["fff6"],
72
+ allow_duplicates: true,
73
+ });
74
+ this.#isScanning = true;
75
+ }
76
+
77
+ async stopScanning(): Promise<void> {
78
+ if (!this.#isScanning) {
79
+ return;
80
+ }
81
+
82
+ if (!this.#handler.connected) {
83
+ this.#isScanning = false;
84
+ return;
85
+ }
86
+
87
+ logger.debug("Stop BLE scanning via proxy ...");
88
+ await this.#handler.sendCommand(BleProxyCommand.StopScan);
89
+ this.#isScanning = false;
90
+ }
91
+
92
+ close(): void {
93
+ this.#isScanning = false;
94
+ this.#handler.eventReceived.off(this.#eventObserver);
95
+ }
96
+
97
+ #handleDeviceDiscovered(data: DeviceDiscoveredData): void {
98
+ const { address, name, rssi, connectable, service_data } = data;
99
+
100
+ // service_data keys may arrive in any UUID form the proxy client chose: noble's compact
101
+ // 32-char form, ESPHome's dashed form, or the 16-bit short form "fff6"/"FFF6". Accept all.
102
+ const serviceData = new Map<string, Uint8Array>();
103
+ let matterServiceData: Uint8Array | undefined;
104
+ if (service_data) {
105
+ for (const [uuid, base64Value] of Object.entries(service_data)) {
106
+ const bytes = Buffer.from(base64Value, "base64");
107
+ serviceData.set(uuid, bytes);
108
+ if (MatterBle.isServiceUuid(uuid)) {
109
+ matterServiceData = bytes;
110
+ }
111
+ }
112
+ }
113
+
114
+ const peripheral: ProxyPeripheral = {
115
+ address,
116
+ name,
117
+ rssi,
118
+ connectable,
119
+ serviceData,
120
+ };
121
+
122
+ if (!connectable) {
123
+ logger.debug(`Peripheral ${address} is not connectable, ignoring`);
124
+ return;
125
+ }
126
+
127
+ if (matterServiceData === undefined || matterServiceData.length !== 8) {
128
+ logger.debug(`Peripheral ${address} does not advertise valid Matter service data, ignoring`);
129
+ return;
130
+ }
131
+
132
+ logger.debug(`Discovered device ${address} (${name ?? "unnamed"}) via proxy`);
133
+ this.#discoveredPeripherals.set(address, { peripheral, matterServiceData });
134
+ this.#deviceDiscoveredCallback?.(peripheral, matterServiceData);
135
+ }
136
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { BleScanner as BaseBleScanner, type DiscoveredBleDevice } from "@matter/main/protocol";
8
+ import type { ProxyBleClient, ProxyPeripheral } from "./ProxyBleClient.js";
9
+
10
+ export type { DiscoveredBleDevice } from "@matter/main/protocol";
11
+
12
+ export type DiscoveredProxyBleDevice = Omit<DiscoveredBleDevice, "peripheral"> & { peripheral: ProxyPeripheral };
13
+
14
+ /**
15
+ * BLE scanner that discovers Matter devices through the BLE proxy.
16
+ *
17
+ * Extends matter.js's base `BleScanner`, which already handles the
18
+ * `findCommissionableDevicesContinuously` waiter loop, advertisement parsing,
19
+ * and cancellation semantics. We only narrow the `getDiscoveredDevice` return
20
+ * type to expose the proxy-side `ProxyPeripheral` and route `closeClient`
21
+ * through `ProxyBleClient.close()`.
22
+ */
23
+ export class ProxyBleScanner extends BaseBleScanner {
24
+ readonly #proxyClient: ProxyBleClient;
25
+
26
+ constructor(proxyClient: ProxyBleClient) {
27
+ super(proxyClient);
28
+ this.#proxyClient = proxyClient;
29
+ }
30
+
31
+ override getDiscoveredDevice(address: string): DiscoveredProxyBleDevice {
32
+ return super.getDiscoveredDevice(address) as DiscoveredProxyBleDevice;
33
+ }
34
+
35
+ protected override closeClient(): void {
36
+ this.#proxyClient.close();
37
+ }
38
+ }