@matter-server/ble-proxy 0.7.2-alpha.0-20260601-e778038 → 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.
- package/dist/esm/BleProxyConnection.d.ts +34 -0
- package/dist/esm/BleProxyConnection.d.ts.map +1 -0
- package/dist/esm/BleProxyConnection.js +210 -0
- package/dist/esm/BleProxyConnection.js.map +6 -0
- package/dist/esm/BleProxyHandler.d.ts +15 -20
- package/dist/esm/BleProxyHandler.d.ts.map +1 -1
- package/dist/esm/BleProxyHandler.js +114 -183
- package/dist/esm/BleProxyHandler.js.map +1 -1
- package/dist/esm/ProxyBleChannel.d.ts.map +1 -1
- package/dist/esm/ProxyBleChannel.js +70 -40
- package/dist/esm/ProxyBleChannel.js.map +1 -1
- package/dist/esm/ProxyBleClient.d.ts.map +1 -1
- package/dist/esm/ProxyBleClient.js +16 -24
- package/dist/esm/ProxyBleClient.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/package.json +21 -20
- package/src/BleProxyConnection.ts +282 -0
- package/src/BleProxyHandler.ts +121 -246
- package/src/ProxyBleChannel.ts +92 -56
- package/src/ProxyBleClient.ts +22 -26
- package/src/index.ts +1 -0
|
@@ -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
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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": "AAAA;AAAA;AAAA;AAAA;AAAA;AAMA,SAAS,eAAe,QAAQ,SAAS,mBAAmB;AAC5D,SAAS,UAAU,iBAAiB;
|
|
4
|
+
"mappings": "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",
|
|
5
5
|
"names": []
|
|
6
6
|
}
|
package/dist/esm/index.d.ts
CHANGED
package/dist/esm/index.d.ts.map
CHANGED
|
@@ -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
package/dist/esm/index.js.map
CHANGED
|
@@ -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,46 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matter-server/ble-proxy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"type": "module",
|
|
3
|
+
"version": "0.8.0",
|
|
5
4
|
"description": "BLE proxy implementation for matter.js - proxies BLE operations over WebSocket",
|
|
5
|
+
"homepage": "https://github.com/matter-js/matterjs-server",
|
|
6
6
|
"bugs": {
|
|
7
7
|
"url": "https://github.com/matter-js/matterjs-server/issues"
|
|
8
8
|
},
|
|
9
|
-
"
|
|
9
|
+
"author": "Open Home Foundation",
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
12
|
"url": "git+https://github.com/matter-js/matterjs-server.git"
|
|
13
13
|
},
|
|
14
|
-
"
|
|
14
|
+
"files": [
|
|
15
|
+
"dist/**/*",
|
|
16
|
+
"src/**/*",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
15
21
|
"main": "dist/esm/index.js",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
16
25
|
"scripts": {
|
|
17
26
|
"clean": "nacho-build clean",
|
|
18
27
|
"build": "nacho-build",
|
|
19
28
|
"build-clean": "nacho-build --clean",
|
|
20
29
|
"noble-ble-proxy": "nacho-run src/example/noble-ble-proxy.ts"
|
|
21
30
|
},
|
|
22
|
-
"engines": {
|
|
23
|
-
"node": ">=20.19.0 <22.0.0 || >=22.13.0"
|
|
24
|
-
},
|
|
25
31
|
"dependencies": {
|
|
26
|
-
"@matter/
|
|
27
|
-
"@matter
|
|
32
|
+
"@matter-server/ws-controller": "0.8.0",
|
|
33
|
+
"@matter/main": "0.17.1-alpha.0-20260602-24f28a953",
|
|
28
34
|
"ws": "^8.21.0"
|
|
29
35
|
},
|
|
30
|
-
"optionalDependencies": {
|
|
31
|
-
"@matter/nodejs-ble": "0.17.0"
|
|
32
|
-
},
|
|
33
36
|
"devDependencies": {
|
|
34
37
|
"@types/node": "^25.9.1",
|
|
35
38
|
"@types/ws": "^8.18.1"
|
|
36
39
|
},
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"publishConfig": {
|
|
44
|
-
"access": "public"
|
|
40
|
+
"optionalDependencies": {
|
|
41
|
+
"@matter/nodejs-ble": "0.17.1-alpha.0-20260602-24f28a953",
|
|
42
|
+
"dbus-next": "npm:@jellybrick/dbus-next@^0.10.3"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=22.13.0"
|
|
45
46
|
}
|
|
46
47
|
}
|
|
@@ -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
|
+
}
|