@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
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@matter-server/ble-proxy",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "BLE proxy implementation for matter.js - proxies BLE operations over WebSocket",
6
+ "bugs": {
7
+ "url": "https://github.com/matter-js/matterjs-server/issues"
8
+ },
9
+ "homepage": "https://github.com/matter-js/matterjs-server",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/matter-js/matterjs-server.git"
13
+ },
14
+ "author": "Open Home Foundation",
15
+ "main": "dist/esm/index.js",
16
+ "scripts": {
17
+ "clean": "nacho-build clean",
18
+ "build": "nacho-build",
19
+ "build-clean": "nacho-build --clean",
20
+ "noble-ble-proxy": "nacho-run src/example/noble-ble-proxy.ts"
21
+ },
22
+ "engines": {
23
+ "node": ">=20.19.0 <22.0.0 || >=22.13.0"
24
+ },
25
+ "dependencies": {
26
+ "@matter/main": "0.17.0-alpha.0-20260520-0ab980a16",
27
+ "@matter-server/ws-controller": "*",
28
+ "ws": "^8.20.1"
29
+ },
30
+ "optionalDependencies": {
31
+ "@matter/nodejs-ble": "0.17.0-alpha.0-20260520-0ab980a16"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^25.9.1",
35
+ "@types/ws": "^8.18.1"
36
+ },
37
+ "files": [
38
+ "dist/**/*",
39
+ "src/**/*",
40
+ "LICENSE",
41
+ "README.md"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { WebServerHandler } from "@matter-server/ws-controller";
8
+ import { createPromise, Logger, Observable, Seconds, Time, Timer, withTimeout } from "@matter/main";
9
+ import type { createServer } from "node:http";
10
+ import { WebSocket, WebSocketServer } from "ws";
11
+ import {
12
+ BLE_PROXY_PROTOCOL_VERSION,
13
+ decodeBinaryFrame,
14
+ encodeBinaryFrame,
15
+ type BinaryFrame,
16
+ type BleProxyCommandMap,
17
+ type BleProxyCommandName,
18
+ type BleProxyEventName,
19
+ type CommandMessage,
20
+ type EventMessage,
21
+ type HelloMessage,
22
+ type ResponseMessage,
23
+ } from "./BleProxyProtocol.js";
24
+
25
+ type HttpServer = ReturnType<typeof createServer>;
26
+
27
+ const logger = Logger.get("BleProxyHandler");
28
+
29
+ const HANDSHAKE_TIMEOUT = Seconds(10);
30
+ /**
31
+ * Default per-command timeout. Long enough to cover the slowest BLE
32
+ * commissioning ops (peripheral connect ≤ 30 s, service discovery on
33
+ * Android-side adapters can take several seconds), short enough that an
34
+ * unresponsive-but-still-connected proxy client doesn't hang commissioning
35
+ * indefinitely.
36
+ */
37
+ const COMMAND_TIMEOUT = Seconds(60);
38
+
39
+ /**
40
+ * WebSocket server handler for the /ble BLE proxy endpoint.
41
+ *
42
+ * Accepts a single client connection, performs the protocol handshake,
43
+ * and routes commands/events/binary frames between the proxy implementation
44
+ * and the connected BLE proxy client.
45
+ */
46
+ export class BleProxyHandler implements WebServerHandler {
47
+ #wss?: WebSocketServer;
48
+ /** Upgrade listener removers, one per HTTP server `register()` call (multi-bind). */
49
+ #removeUpgradeListeners: (() => void)[] = [];
50
+ #client?: WebSocket;
51
+ #handshakeComplete = false;
52
+ #pendingCommands = new Map<
53
+ number,
54
+ { resolver: (result: Record<string, unknown> | undefined) => void; rejecter: (reason?: unknown) => void }
55
+ >();
56
+ /** Command ID counter. Rolls over at 0xFFFF to stay in safe integer range. */
57
+ #nextCommandId = 0;
58
+ #closed = false;
59
+
60
+ /** Emitted when a JSON event is received from the BLE proxy client. */
61
+ readonly eventReceived = new Observable<[event: BleProxyEventName, data: Record<string, unknown>]>();
62
+
63
+ /** Emitted when a binary frame is received from the BLE proxy client. */
64
+ readonly binaryFrameReceived = new Observable<[frame: BinaryFrame]>();
65
+
66
+ get connected(): boolean {
67
+ return this.#handshakeComplete && this.#client?.readyState === WebSocket.OPEN;
68
+ }
69
+
70
+ async register(server: HttpServer): Promise<void> {
71
+ // Use noServer mode with a path-filtered upgrade listener.
72
+ // ws 8.x calls handleUpgrade unconditionally from its own upgrade listener, which
73
+ // sends HTTP 400 for non-matching paths and destroys the socket — breaking other
74
+ // WebSocket endpoints on the same server.
75
+ const wss = (this.#wss = new WebSocketServer({ noServer: true }));
76
+ const upgradeHandler = (
77
+ req: { url?: string; _matterHandledUpgrade?: boolean },
78
+ socket: unknown,
79
+ head: unknown,
80
+ ) => {
81
+ logger.debug(`Upgrade request received: ${req.url}`);
82
+ const path = req.url?.split("?")[0];
83
+ if (path === "/ble") {
84
+ req._matterHandledUpgrade = true;
85
+ wss.handleUpgrade(
86
+ req as Parameters<typeof wss.handleUpgrade>[0],
87
+ socket as Parameters<typeof wss.handleUpgrade>[1],
88
+ head as Parameters<typeof wss.handleUpgrade>[2],
89
+ ws => wss.emit("connection", ws, req),
90
+ );
91
+ }
92
+ };
93
+ server.on("upgrade", upgradeHandler);
94
+ this.#removeUpgradeListeners.push(() => server.removeListener("upgrade", upgradeHandler));
95
+ logger.info("BLE proxy WebSocket endpoint registered on /ble");
96
+
97
+ wss.on("connection", ws => {
98
+ if (this.#closed) {
99
+ ws.close();
100
+ return;
101
+ }
102
+
103
+ if (this.#client) {
104
+ logger.warn("Rejecting new BLE proxy client - only one connection allowed");
105
+ ws.close(4000, "Only one BLE proxy client allowed");
106
+ return;
107
+ }
108
+
109
+ logger.info("BLE proxy client connected, waiting for handshake");
110
+ this.#client = ws;
111
+ this.#handshakeComplete = false;
112
+
113
+ const handshakeTimer = Time.getTimer("BLE proxy handshake timeout", HANDSHAKE_TIMEOUT, () => {
114
+ if (!this.#handshakeComplete) {
115
+ logger.warn("BLE proxy handshake timeout - closing connection");
116
+ ws.close(4001, "Handshake timeout");
117
+ this.#cleanupClient();
118
+ }
119
+ }).start();
120
+
121
+ ws.on("message", (data, isBinary) => {
122
+ if (isBinary) {
123
+ this.#handleBinaryMessage(data as Buffer);
124
+ } else {
125
+ this.#handleTextMessage(data.toString(), handshakeTimer);
126
+ }
127
+ });
128
+
129
+ ws.on("close", () => {
130
+ handshakeTimer.stop();
131
+ logger.info("BLE proxy client disconnected");
132
+ this.#cleanupClient();
133
+ });
134
+
135
+ ws.on("error", err => {
136
+ logger.error("BLE proxy WebSocket error:", err);
137
+ handshakeTimer.stop();
138
+ this.#cleanupClient();
139
+ });
140
+ });
141
+ }
142
+
143
+ unregister(): Promise<void> {
144
+ if (!this.#wss || this.#closed) {
145
+ return Promise.resolve();
146
+ }
147
+
148
+ this.#closed = true;
149
+ for (const remove of this.#removeUpgradeListeners) remove();
150
+ this.#removeUpgradeListeners = [];
151
+
152
+ // Close the connected client
153
+ if (this.#client && this.#client.readyState === WebSocket.OPEN) {
154
+ this.#client.close();
155
+ }
156
+ this.#cleanupClient();
157
+
158
+ const wss = this.#wss;
159
+ this.#wss = undefined;
160
+
161
+ // Close all clients connected to the WSS (safety net)
162
+ wss.clients.forEach(client => {
163
+ if (client.readyState === WebSocket.OPEN) {
164
+ client.close();
165
+ }
166
+ });
167
+
168
+ // Wait for the WebSocket server to close properly
169
+ return new Promise<void>((resolve, reject) => {
170
+ wss.close(err => {
171
+ if (err) {
172
+ reject(err);
173
+ } else {
174
+ resolve();
175
+ }
176
+ });
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Send a typed command to the BLE proxy client and wait for the response.
182
+ * The return type is inferred from the command name via BleProxyCommandMap.
183
+ * Rejects if no client is connected or the client disconnects before responding.
184
+ */
185
+ async sendCommand<C extends BleProxyCommandName>(
186
+ command: C,
187
+ ...rest: BleProxyCommandMap[C]["args"] extends undefined ? [] : [args: BleProxyCommandMap[C]["args"]]
188
+ ): Promise<BleProxyCommandMap[C]["result"]> {
189
+ if (!this.connected || !this.#client) {
190
+ throw new Error("BLE proxy client not connected");
191
+ }
192
+
193
+ const args = rest[0];
194
+ const id = this.#nextCommandId;
195
+ this.#nextCommandId = (this.#nextCommandId + 1) & 0xffff;
196
+ const message: CommandMessage = { id, command };
197
+ if (args !== undefined) {
198
+ message.args = args as Record<string, unknown>;
199
+ }
200
+
201
+ const { promise, resolver, rejecter } = createPromise<Record<string, unknown> | undefined>();
202
+ this.#pendingCommands.set(id, { resolver, rejecter });
203
+
204
+ this.#client.send(JSON.stringify(message), err => {
205
+ if (err) {
206
+ this.#pendingCommands.delete(id);
207
+ rejecter(new Error(`Failed to send command ${command}: ${err.message}`));
208
+ }
209
+ });
210
+
211
+ return withTimeout(COMMAND_TIMEOUT, promise, () => this.#pendingCommands.delete(id)) as Promise<
212
+ BleProxyCommandMap[C]["result"]
213
+ >;
214
+ }
215
+
216
+ /**
217
+ * Send a binary frame to the BLE proxy client.
218
+ */
219
+ sendBinaryFrame(opcode: number, connectionHandle: number, payload: Uint8Array): void {
220
+ if (!this.connected || !this.#client) {
221
+ throw new Error("BLE proxy client not connected");
222
+ }
223
+ const frame = encodeBinaryFrame(opcode, connectionHandle, payload);
224
+ this.#client.send(frame);
225
+ }
226
+
227
+ #handleTextMessage(raw: string, handshakeTimer: Timer): void {
228
+ let parsed: unknown;
229
+ try {
230
+ parsed = JSON.parse(raw);
231
+ } catch {
232
+ logger.warn("Received invalid JSON from BLE proxy client");
233
+ return;
234
+ }
235
+
236
+ const msg = parsed as Record<string, unknown>;
237
+
238
+ // Handle handshake
239
+ if (!this.#handshakeComplete) {
240
+ this.#handleHandshake(msg, handshakeTimer);
241
+ return;
242
+ }
243
+
244
+ // Handle response to a pending command
245
+ if ("id" in msg && "success" in msg) {
246
+ this.#handleResponse(msg as unknown as ResponseMessage);
247
+ return;
248
+ }
249
+
250
+ // Handle event
251
+ if ("event" in msg && "data" in msg) {
252
+ this.#handleEvent(msg as unknown as EventMessage);
253
+ return;
254
+ }
255
+
256
+ logger.warn("Received unknown message from BLE proxy client:", msg);
257
+ }
258
+
259
+ #handleHandshake(msg: Record<string, unknown>, handshakeTimer: Timer): void {
260
+ if (msg.type !== "hello") {
261
+ logger.warn(`Expected hello message, got: ${JSON.stringify(msg)}`);
262
+ this.#client?.close(4002, "Expected hello message");
263
+ this.#cleanupClient();
264
+ return;
265
+ }
266
+
267
+ const hello = msg as unknown as HelloMessage;
268
+ handshakeTimer.stop();
269
+
270
+ if (hello.version !== BLE_PROXY_PROTOCOL_VERSION) {
271
+ logger.warn(
272
+ `BLE proxy client version ${hello.version} not supported (server supports ${BLE_PROXY_PROTOCOL_VERSION})`,
273
+ );
274
+ this.#client?.send(
275
+ JSON.stringify({
276
+ type: "hello_response",
277
+ version: BLE_PROXY_PROTOCOL_VERSION,
278
+ error: "unsupported_version",
279
+ message: `Server supports protocol version ${BLE_PROXY_PROTOCOL_VERSION}, client sent version ${hello.version}`,
280
+ }),
281
+ );
282
+ this.#client?.close(4003, "Unsupported protocol version");
283
+ this.#cleanupClient();
284
+ return;
285
+ }
286
+
287
+ this.#handshakeComplete = true;
288
+ this.#client?.send(
289
+ JSON.stringify({
290
+ type: "hello_response",
291
+ version: BLE_PROXY_PROTOCOL_VERSION,
292
+ }),
293
+ );
294
+ logger.info(`BLE proxy handshake complete (version ${BLE_PROXY_PROTOCOL_VERSION})`);
295
+ }
296
+
297
+ #handleResponse(msg: ResponseMessage): void {
298
+ const pending = this.#pendingCommands.get(msg.id);
299
+ if (!pending) {
300
+ logger.warn(`Received response for unknown command id ${msg.id}`);
301
+ return;
302
+ }
303
+ this.#pendingCommands.delete(msg.id);
304
+
305
+ if (msg.success) {
306
+ pending.resolver(msg.result);
307
+ } else {
308
+ pending.rejecter(new Error(`${msg.error}: ${msg.message}`));
309
+ }
310
+ }
311
+
312
+ #handleEvent(msg: EventMessage): void {
313
+ this.eventReceived.emit(msg.event, msg.data);
314
+ }
315
+
316
+ #handleBinaryMessage(data: Buffer): void {
317
+ if (!this.#handshakeComplete) {
318
+ logger.warn("Received binary frame before handshake");
319
+ return;
320
+ }
321
+
322
+ try {
323
+ const frame = decodeBinaryFrame(new Uint8Array(data));
324
+ this.binaryFrameReceived.emit(frame);
325
+ } catch (err) {
326
+ logger.error("Failed to decode binary frame:", err);
327
+ }
328
+ }
329
+
330
+ #cleanupClient(): void {
331
+ if (this.#client) {
332
+ if (this.#client.readyState === WebSocket.OPEN) {
333
+ this.#client.close();
334
+ }
335
+ this.#client = undefined;
336
+ }
337
+ this.#handshakeComplete = false;
338
+
339
+ // Reject all pending commands
340
+ for (const [id, pending] of this.#pendingCommands) {
341
+ pending.rejecter(new Error("BLE proxy client disconnected"));
342
+ this.#pendingCommands.delete(id);
343
+ }
344
+ }
345
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ /**
8
+ * BLE Proxy Protocol constants, types, and codec.
9
+ * See docs/ble-proxy-protocol.md for the full specification.
10
+ */
11
+
12
+ // ─── Protocol Version ────────────────────────────────────────────────────────
13
+
14
+ export const BLE_PROXY_PROTOCOL_VERSION = 1;
15
+
16
+ // ─── Handshake Messages ──────────────────────────────────────────────────────
17
+
18
+ export interface HelloMessage {
19
+ type: "hello";
20
+ version: number;
21
+ }
22
+
23
+ export interface HelloResponseMessage {
24
+ type: "hello_response";
25
+ version: number;
26
+ error?: string;
27
+ message?: string;
28
+ }
29
+
30
+ export type HandshakeMessage = HelloMessage | HelloResponseMessage;
31
+
32
+ // ─── Command Names ───────────────────────────────────────────────────────────
33
+
34
+ export const BleProxyCommand = {
35
+ StartScan: "start_scan",
36
+ StopScan: "stop_scan",
37
+ Connect: "connect",
38
+ Disconnect: "disconnect",
39
+ DiscoverServices: "discover_services",
40
+ DiscoverCharacteristics: "discover_characteristics",
41
+ ReadCharacteristic: "read_characteristic",
42
+ WriteCharacteristic: "write_characteristic",
43
+ SubscribeCharacteristic: "subscribe_characteristic",
44
+ UnsubscribeCharacteristic: "unsubscribe_characteristic",
45
+ RequestMtu: "request_mtu",
46
+ } as const;
47
+
48
+ export type BleProxyCommandName = (typeof BleProxyCommand)[keyof typeof BleProxyCommand];
49
+
50
+ // ─── Event Names ─────────────────────────────────────────────────────────────
51
+
52
+ export const BleProxyEvent = {
53
+ DeviceDiscovered: "device_discovered",
54
+ Disconnected: "disconnected",
55
+ ScanStopped: "scan_stopped",
56
+ CharacteristicNotification: "characteristic_notification",
57
+ } as const;
58
+
59
+ export type BleProxyEventName = (typeof BleProxyEvent)[keyof typeof BleProxyEvent];
60
+
61
+ // ─── Command Args ────────────────────────────────────────────────────────────
62
+
63
+ export interface StartScanArgs {
64
+ service_uuids?: string[];
65
+ allow_duplicates?: boolean;
66
+ }
67
+
68
+ export interface ConnectArgs {
69
+ address: string;
70
+ timeout?: number;
71
+ }
72
+
73
+ export interface DisconnectArgs {
74
+ connection_handle: number;
75
+ }
76
+
77
+ export interface DiscoverServicesArgs {
78
+ connection_handle: number;
79
+ }
80
+
81
+ export interface DiscoverCharacteristicsArgs {
82
+ connection_handle: number;
83
+ service_uuid: string;
84
+ }
85
+
86
+ export interface ReadCharacteristicArgs {
87
+ connection_handle: number;
88
+ characteristic_uuid: string;
89
+ }
90
+
91
+ export interface WriteCharacteristicArgs {
92
+ connection_handle: number;
93
+ characteristic_uuid: string;
94
+ value: string; // base64
95
+ response?: boolean;
96
+ }
97
+
98
+ export interface SubscribeCharacteristicArgs {
99
+ connection_handle: number;
100
+ characteristic_uuid: string;
101
+ }
102
+
103
+ export interface UnsubscribeCharacteristicArgs {
104
+ connection_handle: number;
105
+ characteristic_uuid: string;
106
+ }
107
+
108
+ export interface RequestMtuArgs {
109
+ connection_handle: number;
110
+ mtu: number;
111
+ }
112
+
113
+ // ─── Command Results ─────────────────────────────────────────────────────────
114
+
115
+ export interface ConnectResult {
116
+ connection_handle: number;
117
+ mtu: number;
118
+ }
119
+
120
+ export interface DiscoverServicesResult {
121
+ services: Array<{ uuid: string }>;
122
+ }
123
+
124
+ export interface DiscoverCharacteristicsResult {
125
+ characteristics: Array<{
126
+ uuid: string;
127
+ properties: string[];
128
+ }>;
129
+ }
130
+
131
+ export interface ReadCharacteristicResult {
132
+ value: string; // base64
133
+ }
134
+
135
+ export interface RequestMtuResult {
136
+ mtu: number;
137
+ }
138
+
139
+ // ─── Event Data ──────────────────────────────────────────────────────────────
140
+
141
+ export interface DeviceDiscoveredData {
142
+ address: string;
143
+ name?: string;
144
+ rssi?: number;
145
+ connectable: boolean;
146
+ service_data?: Record<string, string>; // uuid -> base64
147
+ manufacturer_data?: Record<string, string>; // id -> base64
148
+ service_uuids?: string[];
149
+ }
150
+
151
+ export interface DisconnectedData {
152
+ connection_handle: number;
153
+ reason?: string;
154
+ }
155
+
156
+ export interface ScanStoppedData {
157
+ reason: string;
158
+ }
159
+
160
+ export interface CharacteristicNotificationData {
161
+ connection_handle: number;
162
+ characteristic_uuid: string;
163
+ value: string; // base64
164
+ }
165
+
166
+ // ─── Command Type Map ────────────────────────────────────────────────────────
167
+
168
+ /** Maps each command name to its args and result types for type-safe sendCommand. */
169
+ export interface BleProxyCommandMap {
170
+ [BleProxyCommand.StartScan]: { args: StartScanArgs; result: void };
171
+ [BleProxyCommand.StopScan]: { args: undefined; result: void };
172
+ [BleProxyCommand.Connect]: { args: ConnectArgs; result: ConnectResult };
173
+ [BleProxyCommand.Disconnect]: { args: DisconnectArgs; result: void };
174
+ [BleProxyCommand.DiscoverServices]: { args: DiscoverServicesArgs; result: DiscoverServicesResult };
175
+ [BleProxyCommand.DiscoverCharacteristics]: {
176
+ args: DiscoverCharacteristicsArgs;
177
+ result: DiscoverCharacteristicsResult;
178
+ };
179
+ [BleProxyCommand.ReadCharacteristic]: { args: ReadCharacteristicArgs; result: ReadCharacteristicResult };
180
+ [BleProxyCommand.WriteCharacteristic]: { args: WriteCharacteristicArgs; result: void };
181
+ [BleProxyCommand.SubscribeCharacteristic]: { args: SubscribeCharacteristicArgs; result: void };
182
+ [BleProxyCommand.UnsubscribeCharacteristic]: { args: UnsubscribeCharacteristicArgs; result: void };
183
+ [BleProxyCommand.RequestMtu]: { args: RequestMtuArgs; result: RequestMtuResult };
184
+ }
185
+
186
+ // ─── JSON Message Envelopes ──────────────────────────────────────────────────
187
+
188
+ export interface CommandMessage {
189
+ id: number;
190
+ command: BleProxyCommandName;
191
+ args?: Record<string, unknown>;
192
+ }
193
+
194
+ export interface SuccessResponseMessage {
195
+ id: number;
196
+ success: true;
197
+ result?: Record<string, unknown>;
198
+ }
199
+
200
+ export interface ErrorResponseMessage {
201
+ id: number;
202
+ success: false;
203
+ error: string;
204
+ message: string;
205
+ }
206
+
207
+ export type ResponseMessage = SuccessResponseMessage | ErrorResponseMessage;
208
+
209
+ export interface EventMessage {
210
+ event: BleProxyEventName;
211
+ data: Record<string, unknown>;
212
+ }
213
+
214
+ // ─── Error Codes ─────────────────────────────────────────────────────────────
215
+
216
+ export const BleProxyErrorCode = {
217
+ BluetoothUnavailable: "bluetooth_unavailable",
218
+ AlreadyScanning: "already_scanning",
219
+ NotScanning: "not_scanning",
220
+ DeviceNotFound: "device_not_found",
221
+ ConnectionFailed: "connection_failed",
222
+ AlreadyConnected: "already_connected",
223
+ NotConnected: "not_connected",
224
+ Timeout: "timeout",
225
+ ServiceNotFound: "service_not_found",
226
+ CharacteristicNotFound: "characteristic_not_found",
227
+ ReadFailed: "read_failed",
228
+ WriteFailed: "write_failed",
229
+ SubscribeFailed: "subscribe_failed",
230
+ NotSubscribed: "not_subscribed",
231
+ NotifyNotSupported: "notify_not_supported",
232
+ MtuRequestFailed: "mtu_request_failed",
233
+ DiscoveryFailed: "discovery_failed",
234
+ InternalError: "internal_error",
235
+ } as const;
236
+
237
+ export type BleProxyErrorCodeValue = (typeof BleProxyErrorCode)[keyof typeof BleProxyErrorCode];
238
+
239
+ // ─── Binary Frame Opcodes ────────────────────────────────────────────────────
240
+
241
+ export const BinaryFrameOpcode = {
242
+ /** Server -> Client: write payload to the active write characteristic */
243
+ WriteData: 0x01,
244
+ /** Client -> Server: notification data from a subscribed characteristic */
245
+ Notification: 0x02,
246
+ /** Client -> Server: response data for a read_characteristic command */
247
+ ReadResponse: 0x03,
248
+ } as const;
249
+
250
+ export type BinaryFrameOpcodeValue = (typeof BinaryFrameOpcode)[keyof typeof BinaryFrameOpcode];
251
+
252
+ // ─── Binary Frame Codec ──────────────────────────────────────────────────────
253
+
254
+ /**
255
+ * Binary frame layout:
256
+ * [1 byte: opcode] [2 bytes: connection_handle big-endian] [N bytes: payload]
257
+ *
258
+ * Minimum frame size: 3 bytes (opcode + handle, empty payload).
259
+ */
260
+
261
+ const BINARY_FRAME_HEADER_SIZE = 3;
262
+
263
+ export interface BinaryFrame {
264
+ opcode: number;
265
+ connectionHandle: number;
266
+ payload: Uint8Array;
267
+ }
268
+
269
+ export function encodeBinaryFrame(opcode: number, connectionHandle: number, payload: Uint8Array): Uint8Array {
270
+ const frame = new Uint8Array(BINARY_FRAME_HEADER_SIZE + payload.length);
271
+ frame[0] = opcode & 0xff;
272
+ frame[1] = (connectionHandle >> 8) & 0xff;
273
+ frame[2] = connectionHandle & 0xff;
274
+ frame.set(payload, BINARY_FRAME_HEADER_SIZE);
275
+ return frame;
276
+ }
277
+
278
+ export function decodeBinaryFrame(data: Uint8Array): BinaryFrame {
279
+ if (data.length < BINARY_FRAME_HEADER_SIZE) {
280
+ throw new Error(`Binary frame too short: ${data.length} bytes, minimum ${BINARY_FRAME_HEADER_SIZE}`);
281
+ }
282
+ return {
283
+ opcode: data[0],
284
+ connectionHandle: (data[1] << 8) | data[2],
285
+ payload: data.subarray(BINARY_FRAME_HEADER_SIZE),
286
+ };
287
+ }