@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.
- package/README.md +116 -0
- package/dist/esm/BleProxyHandler.d.ts +39 -0
- package/dist/esm/BleProxyHandler.d.ts.map +1 -0
- package/dist/esm/BleProxyHandler.js +256 -0
- package/dist/esm/BleProxyHandler.js.map +6 -0
- package/dist/esm/BleProxyProtocol.d.ts +230 -0
- package/dist/esm/BleProxyProtocol.d.ts.map +1 -0
- package/dist/esm/BleProxyProtocol.js +82 -0
- package/dist/esm/BleProxyProtocol.js.map +6 -0
- package/dist/esm/ProxyBle.d.ts +24 -0
- package/dist/esm/ProxyBle.d.ts.map +1 -0
- package/dist/esm/ProxyBle.js +50 -0
- package/dist/esm/ProxyBle.js.map +6 -0
- package/dist/esm/ProxyBleChannel.d.ts +38 -0
- package/dist/esm/ProxyBleChannel.d.ts.map +1 -0
- package/dist/esm/ProxyBleChannel.js +314 -0
- package/dist/esm/ProxyBleChannel.js.map +6 -0
- package/dist/esm/ProxyBleClient.d.ts +32 -0
- package/dist/esm/ProxyBleClient.d.ts.map +1 -0
- package/dist/esm/ProxyBleClient.js +100 -0
- package/dist/esm/ProxyBleClient.js.map +6 -0
- package/dist/esm/ProxyBleScanner.d.ts +27 -0
- package/dist/esm/ProxyBleScanner.d.ts.map +1 -0
- package/dist/esm/ProxyBleScanner.js +23 -0
- package/dist/esm/ProxyBleScanner.js.map +6 -0
- package/dist/esm/example/NobleBleProxyClient.d.ts +12 -0
- package/dist/esm/example/NobleBleProxyClient.d.ts.map +1 -0
- package/dist/esm/example/NobleBleProxyClient.js +520 -0
- package/dist/esm/example/NobleBleProxyClient.js.map +6 -0
- package/dist/esm/example/noble-ble-proxy.d.ts +7 -0
- package/dist/esm/example/noble-ble-proxy.d.ts.map +1 -0
- package/dist/esm/example/noble-ble-proxy.js +45 -0
- package/dist/esm/example/noble-ble-proxy.js.map +6 -0
- package/dist/esm/index.d.ts +15 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +12 -0
- package/dist/esm/index.js.map +6 -0
- package/dist/esm/package.json +3 -0
- package/package.json +46 -0
- package/src/BleProxyHandler.ts +345 -0
- package/src/BleProxyProtocol.ts +287 -0
- package/src/ProxyBle.ts +64 -0
- package/src/ProxyBleChannel.ts +407 -0
- package/src/ProxyBleClient.ts +136 -0
- package/src/ProxyBleScanner.ts +38 -0
- package/src/example/NobleBleProxyClient.ts +679 -0
- package/src/example/noble-ble-proxy.ts +62 -0
- package/src/index.ts +16 -0
- package/src/tsconfig.json +15 -0
- package/src/tsconfig.tsbuildinfo +1 -0
package/src/ProxyBle.ts
ADDED
|
@@ -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
|
+
}
|