@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
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025-2026 Open Home Foundation
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Noble-based BLE proxy client - reference implementation of the BLE proxy protocol client side.
|
|
9
|
+
*
|
|
10
|
+
* Connects to the matter-server's /ble WebSocket endpoint and executes BLE commands
|
|
11
|
+
* using Noble (via @matter/nodejs-ble). This provides:
|
|
12
|
+
* - A reference implementation for the BLE proxy protocol
|
|
13
|
+
* - A standalone local BLE bridge without Home Assistant
|
|
14
|
+
* - An integration testing tool
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { WebSocket } from "ws";
|
|
18
|
+
import {
|
|
19
|
+
BLE_PROXY_PROTOCOL_VERSION,
|
|
20
|
+
BinaryFrameOpcode,
|
|
21
|
+
type BleProxyCommandName,
|
|
22
|
+
type CommandMessage,
|
|
23
|
+
type ConnectArgs,
|
|
24
|
+
type DeviceDiscoveredData,
|
|
25
|
+
type DiscoverCharacteristicsArgs,
|
|
26
|
+
type DiscoverServicesArgs,
|
|
27
|
+
type ReadCharacteristicArgs,
|
|
28
|
+
type SubscribeCharacteristicArgs,
|
|
29
|
+
type UnsubscribeCharacteristicArgs,
|
|
30
|
+
type WriteCharacteristicArgs,
|
|
31
|
+
decodeBinaryFrame,
|
|
32
|
+
encodeBinaryFrame,
|
|
33
|
+
} from "../BleProxyProtocol.js";
|
|
34
|
+
|
|
35
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
36
|
+
return Promise.race([promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(message)), ms))]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ts(): string {
|
|
40
|
+
const d = new Date();
|
|
41
|
+
const pad = (n: number, w = 2) => n.toString().padStart(w, "0");
|
|
42
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function log(...args: unknown[]): void {
|
|
46
|
+
process.stdout.write(`${ts()} `);
|
|
47
|
+
console.log(...args);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function warn(...args: unknown[]): void {
|
|
51
|
+
process.stdout.write(`${ts()} `);
|
|
52
|
+
console.warn(...args);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function error(...args: unknown[]): void {
|
|
56
|
+
process.stdout.write(`${ts()} `);
|
|
57
|
+
console.error(...args);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Peripheral = import("@stoprocent/noble").Peripheral;
|
|
61
|
+
type Characteristic = import("@stoprocent/noble").Characteristic;
|
|
62
|
+
type Service = import("@stoprocent/noble").Service;
|
|
63
|
+
|
|
64
|
+
interface ConnectionState {
|
|
65
|
+
peripheral: Peripheral;
|
|
66
|
+
services: Map<string, Service>;
|
|
67
|
+
characteristics: Map<string, Characteristic>;
|
|
68
|
+
subscriptions: Map<string, Characteristic>;
|
|
69
|
+
lastWriteCharacteristic?: Characteristic;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Fields that, when changed, justify re-emitting a device_discovered event. */
|
|
73
|
+
interface DiscoverFingerprint {
|
|
74
|
+
name: string;
|
|
75
|
+
connectable: boolean;
|
|
76
|
+
serviceUuids: string;
|
|
77
|
+
serviceData: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type CommandHandler = (id: number, args: Record<string, unknown>) => Promise<void>;
|
|
81
|
+
|
|
82
|
+
export class NobleBleProxyClient {
|
|
83
|
+
readonly #serverUrl: string;
|
|
84
|
+
readonly #hciId?: number;
|
|
85
|
+
#ws?: WebSocket;
|
|
86
|
+
#noble?: import("@stoprocent/noble").Noble;
|
|
87
|
+
#connections = new Map<number, ConnectionState>();
|
|
88
|
+
#nextHandle = 1;
|
|
89
|
+
#discoveredPeripherals = new Map<string, Peripheral>();
|
|
90
|
+
#lastDiscoverFingerprint = new Map<string, DiscoverFingerprint>();
|
|
91
|
+
#commandHandlers = new Map<BleProxyCommandName, CommandHandler>();
|
|
92
|
+
#closing = false;
|
|
93
|
+
|
|
94
|
+
constructor(serverUrl: string, hciId?: number) {
|
|
95
|
+
this.#serverUrl = serverUrl;
|
|
96
|
+
this.#hciId = hciId;
|
|
97
|
+
this.#registerCommandHandlers();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async connect(): Promise<void> {
|
|
101
|
+
// Load Noble dynamically (it's an optional dependency)
|
|
102
|
+
await this.#loadNoble();
|
|
103
|
+
|
|
104
|
+
return new Promise<void>((resolve, reject) => {
|
|
105
|
+
const ws = new WebSocket(this.#serverUrl);
|
|
106
|
+
this.#ws = ws;
|
|
107
|
+
|
|
108
|
+
ws.on("open", () => {
|
|
109
|
+
// Send hello handshake
|
|
110
|
+
ws.send(JSON.stringify({ type: "hello", version: BLE_PROXY_PROTOCOL_VERSION }));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
let handshakeComplete = false;
|
|
114
|
+
|
|
115
|
+
ws.on("message", (data, isBinary) => {
|
|
116
|
+
if (isBinary) {
|
|
117
|
+
this.#handleBinaryFrame(Buffer.from(data as ArrayBuffer));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const msg = JSON.parse(data.toString());
|
|
122
|
+
|
|
123
|
+
if (!handshakeComplete) {
|
|
124
|
+
if (msg.type === "hello_response") {
|
|
125
|
+
if (msg.error) {
|
|
126
|
+
reject(new Error(`Handshake failed: ${msg.error} - ${msg.message}`));
|
|
127
|
+
ws.close();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
handshakeComplete = true;
|
|
131
|
+
log(`BLE proxy handshake complete (protocol v${msg.version})`);
|
|
132
|
+
resolve();
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Handle command from server
|
|
138
|
+
if ("id" in msg && "command" in msg) {
|
|
139
|
+
this.#handleCommand(msg as CommandMessage).catch(err =>
|
|
140
|
+
error(`Unhandled error in command handler: ${(err as Error).message}`),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
ws.on("close", (code, reason) => {
|
|
146
|
+
if (!this.#closing) {
|
|
147
|
+
log(`Disconnected from server (code=${code}${reason.length ? `, reason=${reason}` : ""})`);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
152
|
+
let body = "";
|
|
153
|
+
res.on("data", (chunk: Buffer) => (body += chunk.toString()));
|
|
154
|
+
res.on("end", () => {
|
|
155
|
+
const err = new Error(`Unexpected server response: ${res.statusCode}`);
|
|
156
|
+
error(`Server rejected WebSocket upgrade: HTTP ${res.statusCode}`, body ? `- Body: ${body}` : "");
|
|
157
|
+
error("Hint: Make sure the server is running with --ble-proxy (server must expose /ble endpoint)");
|
|
158
|
+
reject(err);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
ws.on("error", err => {
|
|
163
|
+
if (!handshakeComplete) {
|
|
164
|
+
reject(err);
|
|
165
|
+
} else {
|
|
166
|
+
error("WebSocket error:", err);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
close(): void {
|
|
173
|
+
this.#closing = true;
|
|
174
|
+
// Disconnect all BLE peripherals
|
|
175
|
+
for (const [handle, conn] of this.#connections) {
|
|
176
|
+
if (conn.peripheral.state === "connected") {
|
|
177
|
+
conn.peripheral
|
|
178
|
+
.disconnectAsync()
|
|
179
|
+
.catch(err =>
|
|
180
|
+
warn(`[CONN] handle=${handle} disconnect during shutdown failed: ${(err as Error).message}`),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
this.#connections.clear();
|
|
185
|
+
this.#noble?.stop();
|
|
186
|
+
this.#ws?.close();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async #loadNoble(): Promise<void> {
|
|
190
|
+
if (this.#hciId !== undefined) {
|
|
191
|
+
process.env.NOBLE_HCI_DEVICE_ID = this.#hciId.toString();
|
|
192
|
+
}
|
|
193
|
+
// Dynamic import since @matter/nodejs-ble is optional. Some noble builds export a
|
|
194
|
+
// factory function rather than a ready-made instance — handle both shapes.
|
|
195
|
+
const noble = (await import("@stoprocent/noble")).default;
|
|
196
|
+
const nobleAsUnknown: unknown = noble;
|
|
197
|
+
if (typeof nobleAsUnknown === "function") {
|
|
198
|
+
const factory = nobleAsUnknown as (opts: { extended: boolean }) => typeof noble;
|
|
199
|
+
this.#noble = factory({ extended: false });
|
|
200
|
+
} else {
|
|
201
|
+
this.#noble = noble;
|
|
202
|
+
}
|
|
203
|
+
// Surface noble's internal warnings (unknown peripheral, missing service, etc.). matter.js
|
|
204
|
+
// native NobleBleClient also picks these up via its general logger; the proxy needs them
|
|
205
|
+
// explicitly because diagnostics here run in a different process.
|
|
206
|
+
this.#noble.on("warning", (message: string) => warn(`[NOBLE] warning: ${message}`));
|
|
207
|
+
this.#noble.on("stateChange", (state: string) => log(`[NOBLE] stateChange: ${state}`));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#registerCommandHandlers(): void {
|
|
211
|
+
this.#commandHandlers.set("start_scan", (id, _args) => this.#handleStartScan(id));
|
|
212
|
+
this.#commandHandlers.set("stop_scan", id => this.#handleStopScan(id));
|
|
213
|
+
this.#commandHandlers.set("connect", (id, args) => this.#handleConnect(id, args as unknown as ConnectArgs));
|
|
214
|
+
this.#commandHandlers.set("disconnect", (id, args) =>
|
|
215
|
+
this.#handleDisconnect(id, (args as { connection_handle: number }).connection_handle),
|
|
216
|
+
);
|
|
217
|
+
this.#commandHandlers.set("discover_services", (id, args) =>
|
|
218
|
+
this.#handleDiscoverServices(id, args as unknown as DiscoverServicesArgs),
|
|
219
|
+
);
|
|
220
|
+
this.#commandHandlers.set("discover_characteristics", (id, args) =>
|
|
221
|
+
this.#handleDiscoverCharacteristics(id, args as unknown as DiscoverCharacteristicsArgs),
|
|
222
|
+
);
|
|
223
|
+
this.#commandHandlers.set("read_characteristic", (id, args) =>
|
|
224
|
+
this.#handleReadCharacteristic(id, args as unknown as ReadCharacteristicArgs),
|
|
225
|
+
);
|
|
226
|
+
this.#commandHandlers.set("write_characteristic", (id, args) =>
|
|
227
|
+
this.#handleWriteCharacteristic(id, args as unknown as WriteCharacteristicArgs),
|
|
228
|
+
);
|
|
229
|
+
this.#commandHandlers.set("subscribe_characteristic", (id, args) =>
|
|
230
|
+
this.#handleSubscribeCharacteristic(id, args as unknown as SubscribeCharacteristicArgs),
|
|
231
|
+
);
|
|
232
|
+
this.#commandHandlers.set("unsubscribe_characteristic", (id, args) =>
|
|
233
|
+
this.#handleUnsubscribeCharacteristic(id, args as unknown as UnsubscribeCharacteristicArgs),
|
|
234
|
+
);
|
|
235
|
+
this.#commandHandlers.set("request_mtu", (id, args) =>
|
|
236
|
+
this.#handleRequestMtu(
|
|
237
|
+
id,
|
|
238
|
+
(args as { connection_handle: number; mtu: number }).connection_handle,
|
|
239
|
+
(args as { mtu: number }).mtu,
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async #handleCommand(msg: CommandMessage): Promise<void> {
|
|
245
|
+
const argsStr = msg.args ? JSON.stringify(msg.args) : "";
|
|
246
|
+
log(`[←CMD] id=${msg.id} ${msg.command}${argsStr ? ` ${argsStr}` : ""}`);
|
|
247
|
+
const handler = this.#commandHandlers.get(msg.command);
|
|
248
|
+
if (!handler) {
|
|
249
|
+
error(`[→ERR] id=${msg.id} Unknown command: ${msg.command}`);
|
|
250
|
+
this.#sendError(msg.id, "internal_error", `Unknown command: ${msg.command}`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
await handler(msg.id, msg.args ?? {});
|
|
255
|
+
} catch (err) {
|
|
256
|
+
error(`[→ERR] id=${msg.id} ${msg.command} threw: ${(err as Error).message}`);
|
|
257
|
+
this.#sendError(msg.id, "internal_error", `${(err as Error).message}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ─── Command Handlers ────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
async #handleStartScan(id: number): Promise<void> {
|
|
264
|
+
if (!this.#noble) {
|
|
265
|
+
this.#sendError(id, "bluetooth_unavailable", "Noble not initialized");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
this.#lastDiscoverFingerprint.clear();
|
|
270
|
+
// Remove any existing discover listeners to prevent duplicates on repeated scans
|
|
271
|
+
this.#noble.removeAllListeners("discover");
|
|
272
|
+
this.#noble.on("discover", (peripheral: Peripheral) => {
|
|
273
|
+
// On macOS, peripheral.address is often empty — fall back to peripheral.id (UUID)
|
|
274
|
+
const address = peripheral.address || peripheral.id;
|
|
275
|
+
this.#discoveredPeripherals.set(address, peripheral);
|
|
276
|
+
|
|
277
|
+
const serviceData: Record<string, string> = {};
|
|
278
|
+
for (const sd of peripheral.advertisement.serviceData ?? []) {
|
|
279
|
+
serviceData[sd.uuid] = Buffer.from(sd.data).toString("base64");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const name = peripheral.advertisement.localName ?? "(unnamed)";
|
|
283
|
+
const connectable = peripheral.connectable ?? false;
|
|
284
|
+
const serviceUuids = peripheral.advertisement.serviceUuids ?? [];
|
|
285
|
+
|
|
286
|
+
const fingerprint: DiscoverFingerprint = {
|
|
287
|
+
name,
|
|
288
|
+
connectable,
|
|
289
|
+
serviceUuids: serviceUuids.join(","),
|
|
290
|
+
serviceData: Object.entries(serviceData)
|
|
291
|
+
.map(([uuid, data]) => `${uuid}=${data}`)
|
|
292
|
+
.sort()
|
|
293
|
+
.join("|"),
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const prev = this.#lastDiscoverFingerprint.get(address);
|
|
297
|
+
const changed =
|
|
298
|
+
!prev ||
|
|
299
|
+
prev.name !== fingerprint.name ||
|
|
300
|
+
prev.connectable !== fingerprint.connectable ||
|
|
301
|
+
prev.serviceUuids !== fingerprint.serviceUuids ||
|
|
302
|
+
prev.serviceData !== fingerprint.serviceData;
|
|
303
|
+
|
|
304
|
+
if (!changed) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
this.#lastDiscoverFingerprint.set(address, fingerprint);
|
|
308
|
+
|
|
309
|
+
log(
|
|
310
|
+
`[EVT] device_discovered addr=${address} name="${name}" rssi=${peripheral.rssi}` +
|
|
311
|
+
` services=${JSON.stringify(serviceUuids)}` +
|
|
312
|
+
` serviceData=${JSON.stringify(Object.keys(serviceData))}`,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const event: DeviceDiscoveredData = {
|
|
316
|
+
address,
|
|
317
|
+
name: peripheral.advertisement.localName,
|
|
318
|
+
rssi: peripheral.rssi,
|
|
319
|
+
connectable,
|
|
320
|
+
service_data: serviceData,
|
|
321
|
+
service_uuids: serviceUuids,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
this.#sendEvent("device_discovered", event as unknown as Record<string, unknown>);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await this.#noble.startScanningAsync(["fff6"], true);
|
|
328
|
+
log("[SCAN] BLE scan started (filter: fff6)");
|
|
329
|
+
this.#sendSuccess(id);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async #handleStopScan(id: number): Promise<void> {
|
|
333
|
+
await this.#noble?.stopScanningAsync();
|
|
334
|
+
log("[SCAN] BLE scan stopped");
|
|
335
|
+
this.#sendSuccess(id);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async #handleConnect(id: number, args: ConnectArgs): Promise<void> {
|
|
339
|
+
const peripheral = this.#discoveredPeripherals.get(args.address);
|
|
340
|
+
if (!peripheral) {
|
|
341
|
+
error(
|
|
342
|
+
`[CONN] No peripheral found for address "${args.address}". Known: ${[...this.#discoveredPeripherals.keys()].join(", ")}`,
|
|
343
|
+
);
|
|
344
|
+
this.#sendError(id, "device_not_found", `No device found for address ${args.address}`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const handle = this.#nextHandle++;
|
|
349
|
+
const connState: ConnectionState = {
|
|
350
|
+
peripheral,
|
|
351
|
+
services: new Map(),
|
|
352
|
+
characteristics: new Map(),
|
|
353
|
+
subscriptions: new Map(),
|
|
354
|
+
};
|
|
355
|
+
this.#connections.set(handle, connState);
|
|
356
|
+
|
|
357
|
+
// Track disconnect at every stage so unexpected drops are surfaced rather than silently
|
|
358
|
+
// hanging the awaiting noble promise.
|
|
359
|
+
let disconnectedReason: string | undefined;
|
|
360
|
+
const disconnectListener = () => {
|
|
361
|
+
disconnectedReason = `peripheral disconnected (state=${peripheral.state})`;
|
|
362
|
+
log(`[CONN] Peripheral handle=${handle} disconnected (state=${peripheral.state})`);
|
|
363
|
+
this.#connections.delete(handle);
|
|
364
|
+
this.#sendEvent("disconnected", { connection_handle: handle });
|
|
365
|
+
};
|
|
366
|
+
peripheral.once("disconnect", disconnectListener);
|
|
367
|
+
|
|
368
|
+
log(`[CONN] Connecting to "${args.address}" (state=${peripheral.state})...`);
|
|
369
|
+
try {
|
|
370
|
+
// Pause scanning during connect + GATT discovery. On macOS, scanning concurrently
|
|
371
|
+
// with `service.discoverCharacteristicsAsync` causes the CoreBluetooth delegate
|
|
372
|
+
// callback to never fire; the peripheral stays connected but discovery hangs.
|
|
373
|
+
log(`[SCAN] pausing scan for connect+interview...`);
|
|
374
|
+
await this.#noble!.stopScanningAsync();
|
|
375
|
+
|
|
376
|
+
await peripheral.connectAsync();
|
|
377
|
+
log(`[CONN] Connected handle=${handle} state=${peripheral.state} mtu=${peripheral.mtu ?? "?"}`);
|
|
378
|
+
|
|
379
|
+
log(`[GATT] handle=${handle} discoverServicesAsync(["fff6"])...`);
|
|
380
|
+
const services = await withTimeout(
|
|
381
|
+
peripheral.discoverServicesAsync(["fff6"]),
|
|
382
|
+
30_000,
|
|
383
|
+
"discoverServices(fff6) timed out after 30s",
|
|
384
|
+
);
|
|
385
|
+
log(`[GATT] handle=${handle} services: ${services.map(s => s.uuid).join(", ")} state=${peripheral.state}`);
|
|
386
|
+
|
|
387
|
+
for (const service of services) {
|
|
388
|
+
connState.services.set(service.uuid, service);
|
|
389
|
+
if (service.uuid !== "fff6") continue;
|
|
390
|
+
log(`[GATT] handle=${handle} discoverCharacteristicsAsync() on ${service.uuid}...`);
|
|
391
|
+
const chars = await withTimeout(
|
|
392
|
+
service.discoverCharacteristicsAsync(),
|
|
393
|
+
30_000,
|
|
394
|
+
`discoverCharacteristics(${service.uuid}) timed out after 30s`,
|
|
395
|
+
);
|
|
396
|
+
for (const char of chars) {
|
|
397
|
+
connState.characteristics.set(char.uuid, char);
|
|
398
|
+
}
|
|
399
|
+
log(
|
|
400
|
+
`[GATT] handle=${handle} chars on ${service.uuid}: ${chars.map(c => c.uuid).join(", ")} state=${peripheral.state}`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const mtu = peripheral.mtu ?? 23;
|
|
405
|
+
log(`[GATT] handle=${handle} ready mtu=${mtu}`);
|
|
406
|
+
|
|
407
|
+
// Resume scanning so the server can still observe new devices and rssi updates.
|
|
408
|
+
log(`[SCAN] resuming scan after connect+interview...`);
|
|
409
|
+
await this.#noble!.startScanningAsync(["fff6"], true);
|
|
410
|
+
|
|
411
|
+
this.#sendSuccess(id, { connection_handle: handle, mtu });
|
|
412
|
+
} catch (err) {
|
|
413
|
+
const reason = disconnectedReason ?? (err as Error).message;
|
|
414
|
+
error(`[CONN] handle=${handle} failed: ${reason}`);
|
|
415
|
+
if (this.#connections.has(handle)) {
|
|
416
|
+
this.#connections.delete(handle);
|
|
417
|
+
}
|
|
418
|
+
peripheral.removeListener("disconnect", disconnectListener);
|
|
419
|
+
if (peripheral.state === "connected") {
|
|
420
|
+
peripheral
|
|
421
|
+
.disconnectAsync()
|
|
422
|
+
.catch(disconnectErr =>
|
|
423
|
+
warn(`[CONN] handle=${handle} cleanup disconnect failed: ${(disconnectErr as Error).message}`),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
// Always try to resume scanning so subsequent connect attempts still see devices.
|
|
427
|
+
this.#noble!
|
|
428
|
+
.startScanningAsync(["fff6"], true)
|
|
429
|
+
.catch(scanErr =>
|
|
430
|
+
warn(`[SCAN] failed to resume scanning after connect failure: ${(scanErr as Error).message}`),
|
|
431
|
+
);
|
|
432
|
+
this.#sendError(id, "internal_error", reason);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async #handleDisconnect(id: number, connectionHandle: number): Promise<void> {
|
|
437
|
+
const conn = this.#connections.get(connectionHandle);
|
|
438
|
+
if (!conn) {
|
|
439
|
+
this.#sendError(id, "not_connected", `No connection with handle ${connectionHandle}`);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (conn.peripheral.state === "connected") {
|
|
444
|
+
await conn.peripheral.disconnectAsync();
|
|
445
|
+
}
|
|
446
|
+
this.#connections.delete(connectionHandle);
|
|
447
|
+
this.#sendSuccess(id);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async #handleDiscoverServices(id: number, args: DiscoverServicesArgs): Promise<void> {
|
|
451
|
+
const conn = this.#connections.get(args.connection_handle);
|
|
452
|
+
if (!conn) {
|
|
453
|
+
this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// If pre-fetch populated the cache, serve from it immediately
|
|
458
|
+
if (conn.services.size > 0) {
|
|
459
|
+
const uuids = [...conn.services.keys()];
|
|
460
|
+
log(`[GATT] handle=${args.connection_handle} services from cache: ${uuids.join(", ")}`);
|
|
461
|
+
this.#sendSuccess(id, { services: uuids.map(uuid => ({ uuid })) });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Fallback: lazy discover
|
|
466
|
+
log(`[GATT] handle=${args.connection_handle} discovering services (lazy)...`);
|
|
467
|
+
const services = await withTimeout(
|
|
468
|
+
conn.peripheral.discoverServicesAsync([]),
|
|
469
|
+
10_000,
|
|
470
|
+
"discoverServices timed out after 10s",
|
|
471
|
+
);
|
|
472
|
+
for (const service of services) {
|
|
473
|
+
conn.services.set(service.uuid, service);
|
|
474
|
+
}
|
|
475
|
+
log(`[GATT] handle=${args.connection_handle} discovered services: ${services.map(s => s.uuid).join(", ")}`);
|
|
476
|
+
|
|
477
|
+
this.#sendSuccess(id, {
|
|
478
|
+
services: services.map(s => ({ uuid: s.uuid })),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async #handleDiscoverCharacteristics(id: number, args: DiscoverCharacteristicsArgs): Promise<void> {
|
|
483
|
+
const conn = this.#connections.get(args.connection_handle);
|
|
484
|
+
if (!conn) {
|
|
485
|
+
this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const service = conn.services.get(args.service_uuid);
|
|
490
|
+
if (!service) {
|
|
491
|
+
this.#sendError(id, "service_not_found", `Service ${args.service_uuid} not found`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// If pre-fetch populated characteristics for this service, serve from cache
|
|
496
|
+
const cachedChars = service.characteristics ?? [];
|
|
497
|
+
if (cachedChars.length > 0) {
|
|
498
|
+
log(
|
|
499
|
+
`[GATT] handle=${args.connection_handle} characteristics from cache for ${args.service_uuid}: ` +
|
|
500
|
+
cachedChars.map(c => `${c.uuid}[${c.properties.join(",")}]`).join(", "),
|
|
501
|
+
);
|
|
502
|
+
this.#sendSuccess(id, {
|
|
503
|
+
characteristics: cachedChars.map(c => ({
|
|
504
|
+
uuid: c.uuid,
|
|
505
|
+
properties: c.properties,
|
|
506
|
+
})),
|
|
507
|
+
});
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Fallback: lazy discover
|
|
512
|
+
log(`[GATT] handle=${args.connection_handle} discovering characteristics for ${args.service_uuid} (lazy)...`);
|
|
513
|
+
const characteristics = await withTimeout(
|
|
514
|
+
service.discoverCharacteristicsAsync([]),
|
|
515
|
+
10_000,
|
|
516
|
+
`discoverCharacteristics(${args.service_uuid}) timed out after 10s`,
|
|
517
|
+
);
|
|
518
|
+
for (const char of characteristics) {
|
|
519
|
+
conn.characteristics.set(char.uuid, char);
|
|
520
|
+
}
|
|
521
|
+
log(
|
|
522
|
+
`[GATT] handle=${args.connection_handle} discovered chars for ${args.service_uuid}: ` +
|
|
523
|
+
characteristics.map(c => `${c.uuid}[${c.properties.join(",")}]`).join(", "),
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
this.#sendSuccess(id, {
|
|
527
|
+
characteristics: characteristics.map(c => ({
|
|
528
|
+
uuid: c.uuid,
|
|
529
|
+
properties: c.properties,
|
|
530
|
+
})),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async #handleReadCharacteristic(id: number, args: ReadCharacteristicArgs): Promise<void> {
|
|
535
|
+
const conn = this.#connections.get(args.connection_handle);
|
|
536
|
+
if (!conn) {
|
|
537
|
+
this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const char = this.#findCharacteristic(conn, args.characteristic_uuid);
|
|
542
|
+
if (!char) {
|
|
543
|
+
this.#sendError(id, "characteristic_not_found", `Characteristic ${args.characteristic_uuid} not found`);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const data = await char.readAsync();
|
|
548
|
+
log(`[GATT] read ${args.characteristic_uuid} → ${data.length} bytes`);
|
|
549
|
+
this.#sendSuccess(id, { value: Buffer.from(data).toString("base64") });
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async #handleWriteCharacteristic(id: number, args: WriteCharacteristicArgs): Promise<void> {
|
|
553
|
+
const conn = this.#connections.get(args.connection_handle);
|
|
554
|
+
if (!conn) {
|
|
555
|
+
this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const char = this.#findCharacteristic(conn, args.characteristic_uuid);
|
|
560
|
+
if (!char) {
|
|
561
|
+
this.#sendError(id, "characteristic_not_found", `Characteristic ${args.characteristic_uuid} not found`);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const data = Buffer.from(args.value, "base64");
|
|
566
|
+
const withResponse = args.response ?? false;
|
|
567
|
+
log(`[GATT] write ${args.characteristic_uuid} ${data.length} bytes withResponse=${withResponse}`);
|
|
568
|
+
await char.writeAsync(data, !withResponse);
|
|
569
|
+
conn.lastWriteCharacteristic = char;
|
|
570
|
+
this.#sendSuccess(id);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async #handleSubscribeCharacteristic(id: number, args: SubscribeCharacteristicArgs): Promise<void> {
|
|
574
|
+
const conn = this.#connections.get(args.connection_handle);
|
|
575
|
+
if (!conn) {
|
|
576
|
+
this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const char = this.#findCharacteristic(conn, args.characteristic_uuid);
|
|
581
|
+
if (!char) {
|
|
582
|
+
this.#sendError(id, "characteristic_not_found", `Characteristic ${args.characteristic_uuid} not found`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const handle = args.connection_handle;
|
|
587
|
+
char.on("data", (data: Buffer) => {
|
|
588
|
+
// Send notification as binary frame for efficiency
|
|
589
|
+
log(`[GATT] notify ${args.characteristic_uuid} handle=${handle} ${data.length} bytes`);
|
|
590
|
+
this.#sendBinaryFrame(BinaryFrameOpcode.Notification, handle, new Uint8Array(data));
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
await char.subscribeAsync();
|
|
594
|
+
conn.subscriptions.set(args.characteristic_uuid, char);
|
|
595
|
+
log(`[GATT] subscribe ${args.characteristic_uuid} handle=${handle}`);
|
|
596
|
+
this.#sendSuccess(id);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async #handleUnsubscribeCharacteristic(id: number, args: UnsubscribeCharacteristicArgs): Promise<void> {
|
|
600
|
+
const conn = this.#connections.get(args.connection_handle);
|
|
601
|
+
if (!conn) {
|
|
602
|
+
this.#sendError(id, "not_connected", `No connection with handle ${args.connection_handle}`);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const char = conn.subscriptions.get(args.characteristic_uuid);
|
|
607
|
+
if (!char) {
|
|
608
|
+
this.#sendError(id, "not_subscribed", `Not subscribed to ${args.characteristic_uuid}`);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
await char.unsubscribeAsync();
|
|
613
|
+
char.removeAllListeners("data");
|
|
614
|
+
conn.subscriptions.delete(args.characteristic_uuid);
|
|
615
|
+
log(`[GATT] unsubscribe ${args.characteristic_uuid} handle=${args.connection_handle}`);
|
|
616
|
+
this.#sendSuccess(id);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async #handleRequestMtu(id: number, connectionHandle: number, mtu: number): Promise<void> {
|
|
620
|
+
const conn = this.#connections.get(connectionHandle);
|
|
621
|
+
if (!conn) {
|
|
622
|
+
this.#sendError(id, "not_connected", `No connection with handle ${connectionHandle}`);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
// Noble doesn't have explicit MTU request - return the peripheral's MTU
|
|
626
|
+
const actualMtu = conn.peripheral.mtu ?? mtu;
|
|
627
|
+
log(`[GATT] request_mtu handle=${connectionHandle} requested=${mtu} actual=${actualMtu}`);
|
|
628
|
+
this.#sendSuccess(id, { mtu: actualMtu });
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ─── Binary Frame Handling ───────────────────────────────────────────────
|
|
632
|
+
|
|
633
|
+
#handleBinaryFrame(data: Buffer): void {
|
|
634
|
+
const frame = decodeBinaryFrame(new Uint8Array(data));
|
|
635
|
+
log(`[←BIN] opcode=${frame.opcode} handle=${frame.connectionHandle} payload=${frame.payload.length} bytes`);
|
|
636
|
+
|
|
637
|
+
if (frame.opcode === BinaryFrameOpcode.WriteData) {
|
|
638
|
+
const conn = this.#connections.get(frame.connectionHandle);
|
|
639
|
+
if (!conn?.lastWriteCharacteristic) {
|
|
640
|
+
warn(`[←BIN] WriteData: no lastWriteCharacteristic for handle=${frame.connectionHandle}`);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Matter BTP writes C1 with ATT Write Request (with response). Pass withoutResponse=false.
|
|
645
|
+
conn.lastWriteCharacteristic.writeAsync(Buffer.from(frame.payload), false).catch(err => {
|
|
646
|
+
error("Binary write error:", err);
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
652
|
+
|
|
653
|
+
#findCharacteristic(conn: ConnectionState, uuid: string): Characteristic | undefined {
|
|
654
|
+
// Try exact match first, then case-insensitive
|
|
655
|
+
return (
|
|
656
|
+
conn.characteristics.get(uuid) ??
|
|
657
|
+
conn.characteristics.get(uuid.toLowerCase()) ??
|
|
658
|
+
conn.characteristics.get(uuid.toUpperCase().replace(/-/g, "").toLowerCase())
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
#sendSuccess(id: number, result?: Record<string, unknown>): void {
|
|
663
|
+
this.#ws?.send(JSON.stringify({ id, success: true, result: result ?? {} }));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
#sendError(id: number, error: string, message: string): void {
|
|
667
|
+
this.#ws?.send(JSON.stringify({ id, success: false, error, message }));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
#sendEvent(event: string, data: Record<string, unknown>): void {
|
|
671
|
+
this.#ws?.send(JSON.stringify({ event, data }));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
#sendBinaryFrame(opcode: number, connectionHandle: number, payload: Uint8Array): void {
|
|
675
|
+
log(`[→BIN] opcode=${opcode} handle=${connectionHandle} payload=${payload.length} bytes`);
|
|
676
|
+
const frame = encodeBinaryFrame(opcode, connectionHandle, payload);
|
|
677
|
+
this.#ws?.send(frame);
|
|
678
|
+
}
|
|
679
|
+
}
|