diff --git a/CHANGELOG.md b/CHANGELOG.md index 589054d157..856d9f47d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ The main work (all changes without a GitHub username in brackets in the below li - Fix: One unreadable record in an mDNS message no longer discards the whole message - Enhancement: `Transaction.lock()` takes an exclusive lock on resources without a promise where they are free, and waits instead of throwing where another transaction holds them - Breaking: `DnssdNames.Context.goodbyeProtectionWindow` and `DnssdNames.defaults.goodbyeProtectionWindow` are now `evictionDelay`, and `DnssdName.deleteRecord` no longer takes an `ifOlderThan` argument + - Feature: Added generic WebSocket proxy framing (`net/ws-proxy`: hello handshake, JSON command/event envelope, binary frame codec) shared by WS-based proxy protocols - Enhancement: New `MatterAggregateError.settleSeries()` runs tasks in order, continuing past a failure, and reports the accumulated errors - Enhancement: A storage driver states how long a consumer may buffer dirty values via `StorageDriver.writeCoalescingInterval`, defaulting to 20 minutes; `MemoryStorageDriver` reports `Instant` - Enhancement: `Transaction.Participant` gains `settled()`, invoked once after every participant's pre-commit reports no further mutation and before any of them writes; throwing there rejects the commit, and writes and further participants are refused while it runs @@ -261,6 +262,9 @@ The main work (all changes without a GitHub username in brackets in the below li - Enhancement: `certTest()` accepts test-level `flavors`, skipping a test whose device cannot exist on the run's flavor before the device starts - Enhancement: A cert test's controller commissions from a QR onboarding payload via `CommissioningTarget.qrPairingCode`, on both the matter.js and the chip-tool controller +- @matter/ws-ble + - Feature: Added as new package — BLE-over-WebSocket proxy (hub, `Ble` consumer stack, noble reference client and `matter-ble-proxy` CLI) + - @project-chip/matter.js - Deprecation: Every class, type, and function of the legacy controller API is now marked deprecated and scheduled for removal in 0.19; use the `ServerNode.peers` / `ClientNode` API of `@matter/node` instead - Feature: `CommissioningController` accepts `clientCacheFlushInterval` to override how long node state is buffered before it is written to storage diff --git a/package-lock.json b/package-lock.json index 03ca2a8371..6f6183d56e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "packages/react-native", "packages/cli-tool", "packages/nodejs-ws", + "packages/ws-ble", "examples/control-onoff", "examples/controller", "examples/controller-shared-fabric", @@ -1748,6 +1749,10 @@ "resolved": "packages/types", "link": true }, + "node_modules/@matter/ws-ble": { + "resolved": "packages/ws-ble", + "link": true + }, "node_modules/@memlab/api": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@memlab/api/-/api-2.0.4.tgz", @@ -12630,6 +12635,30 @@ "@matter/testing": "*" } }, + "packages/ws-ble": { + "name": "@matter/ws-ble", + "version": "0.0.0-git", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "*", + "@matter/protocol": "*" + }, + "bin": { + "matter-ble-proxy": "dist/cjs/noble-client/cli.js" + }, + "devDependencies": { + "@matter/node": "*", + "@matter/nodejs": "*", + "@matter/testing": "*" + }, + "engines": { + "node": ">=20.19.0 <21.0.0 || >=22.13.0" + }, + "optionalDependencies": { + "@matter/nodejs-ws": "*", + "@stoprocent/noble": "^2.7.1" + } + }, "support/chip-testing": { "name": "@matter/chip-testing", "version": "0.0.0-git", diff --git a/package.json b/package.json index 0549964386..01dbfefa9d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "packages/react-native", "packages/cli-tool", "packages/nodejs-ws", + "packages/ws-ble", "examples/control-onoff", "examples/controller", "examples/controller-shared-fabric", diff --git a/packages/general/src/net/index.ts b/packages/general/src/net/index.ts index 915f273862..4fd8fa42dd 100644 --- a/packages/general/src/net/index.ts +++ b/packages/general/src/net/index.ts @@ -17,3 +17,4 @@ export * from "./ServerAddressSet.js"; export * from "./tcp/index.js"; export * from "./Transport.js"; export * from "./udp/index.js"; +export * from "./ws-proxy/index.js"; diff --git a/packages/general/src/net/ws-proxy/WsProxyConnection.ts b/packages/general/src/net/ws-proxy/WsProxyConnection.ts new file mode 100644 index 0000000000..291f5e72c1 --- /dev/null +++ b/packages/general/src/net/ws-proxy/WsProxyConnection.ts @@ -0,0 +1,741 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Logger } from "#log/Logger.js"; +import { ImplementationError } from "#MatterError.js"; +import { Duration } from "#time/Duration.js"; +import { Time, Timer } from "#time/Time.js"; +import { Seconds } from "#time/TimeUnit.js"; +import { Bytes } from "#util/Bytes.js"; +import { errorOf } from "#util/Error.js"; +import { Observable } from "#util/Observable.js"; +import { createPromise, PromiseTimeoutError, withTimeout } from "#util/Promises.js"; +import type { HttpEndpoint } from "../http/HttpEndpoint.js"; +import { decodeWsProxyFrame, encodeWsProxyFrame, type WsProxyFrame } from "./WsProxyFrame.js"; +import { + WsProxyCommandError, + WsProxyConnectionClosedError, + type WsProxyCommandMessage, + type WsProxyEventMessage, + type WsProxyHelloMessage, + type WsProxyHelloResponseMessage, + type WsProxyResponseMessage, +} from "./WsProxyMessage.js"; + +const logger = Logger.get("WsProxyConnection"); + +const DEFAULT_HANDSHAKE_TIMEOUT = Seconds(10); +const DEFAULT_COMMAND_TIMEOUT = Seconds(60); +const DEFAULT_ID_PREFIX = "wsp"; + +let connectionIdCounter = 0; + +/** + * Generate a connection ID as a short hex string, rolling over at 0xFFFF. The prefix distinguishes proxy sockets from + * other connections in shared logs. + */ +function generateConnectionId(prefix: string) { + const id = connectionIdCounter; + connectionIdCounter = (connectionIdCounter + 1) & 0xffff; + return `${prefix}${id.toString(16)}`; +} + +function frameHead(payload: Uint8Array) { + return Bytes.toHex(payload.subarray(0, 8)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * One end of a WebSocket proxy connection. + * + * The protocol multiplexes three message kinds over a single WebSocket: + * + * - JSON commands with correlated responses + * - JSON events without responses + * - binary frames (see {@link WsProxyFrame}) + * + * A connection opens with a hello exchange. The *initiator* sends the hello, the *responder* answers it. Once the + * handshake completes both roles are symmetric. + * + * The responder accepts any peer that completes the version handshake; it performs no authentication of its own. + * Securing the endpoint (authentication in front of the upgrade, network isolation, a reverse proxy) is the + * embedder's responsibility. + */ +export class WsProxyConnection { + readonly #connection: HttpEndpoint.WsConnection; + readonly #version: number; + readonly #role: WsProxyConnection.Role; + readonly #hello?: WsProxyConnection.HelloFields; + readonly #handshakeTimeout: Duration; + readonly #commandTimeout: Duration; + readonly #id: string; + readonly #pendingCommands = new Map(); + + #reader?: ReadableStreamDefaultReader; + #writer?: WritableStreamDefaultWriter; + #running?: Promise; + #handshakeTimer?: Timer; + #handshakeComplete = false; + #closedEmitted = false; + #commandHandler?: WsProxyConnection.CommandHandler; + #nextCommandId = 0; + + // An observer that throws must not abort emission or tear down the transport, so every observable installs an + // error handler in place of the default rethrow + readonly #observerFailed = (error: Error) => logger.error(`[${this.#id}] Observer failed:`, error); + + /** Emitted once when the protocol handshake completes. Prefer {@link opened} for waiting on it. */ + readonly handshakeCompleted = new Observable<[]>(this.#observerFailed); + + /** + * Emitted once when the connection stops being usable (close, error, or handshake failure). Stream teardown may + * still be in progress; await {@link close} for that. + */ + readonly closed = new Observable<[]>(this.#observerFailed); + + readonly eventReceived = new Observable<[event: string, data: Record]>(this.#observerFailed); + + readonly frameReceived = new Observable<[frame: WsProxyFrame]>(this.#observerFailed); + + constructor(options: WsProxyConnection.Options) { + this.#connection = options.connection; + this.#version = options.version; + this.#role = options.role; + this.#hello = options.hello; + this.#handshakeTimeout = options.handshakeTimeout ?? DEFAULT_HANDSHAKE_TIMEOUT; + this.#commandTimeout = options.commandTimeout ?? DEFAULT_COMMAND_TIMEOUT; + this.#id = generateConnectionId(options.idPrefix ?? DEFAULT_ID_PREFIX); + } + + get id() { + return this.#id; + } + + /** True once the handshake completed and until the connection closes. */ + get connected() { + return this.#handshakeComplete; + } + + /** + * Begin processing the connection. An initiator sends its hello here; a responder waits for one. + */ + start() { + if (this.#running !== undefined) { + throw new ImplementationError(`[${this.#id}] Connection is already started`); + } + if (this.#closedEmitted) { + throw new ImplementationError(`[${this.#id}] Connection is closed`); + } + + const reader = this.#connection.readable.getReader(); + this.#reader = reader; + this.#writer = this.#connection.writable.getWriter(); + + this.#handshakeTimer = Time.getTimer("Proxy handshake timeout", this.#handshakeTimeout, () => { + if (this.#handshakeComplete) { + return; + } + logger.warn(`[${this.#id}] Handshake timeout - closing connection`); + this.#detach(this.close()); + }).start(); + + this.#running = this.#run(reader); + } + + /** + * Wait for the handshake to complete. + * + * Resolves immediately if the connection is already open and rejects with {@link WsProxyConnectionClosedError} if + * the connection closes first, so consumers do not have to race {@link handshakeCompleted} against + * {@link closed} themselves. + */ + async opened(): Promise { + if (this.#handshakeComplete) { + return; + } + + if (this.#closedEmitted) { + throw new WsProxyConnectionClosedError(`[${this.#id}] Connection closed before the handshake completed`); + } + + const { promise, resolver, rejecter } = createPromise(); + + const onOpened = () => { + release(); + resolver(); + }; + + const onClosed = () => { + release(); + rejecter( + new WsProxyConnectionClosedError(`[${this.#id}] Connection closed before the handshake completed`), + ); + }; + + const release = () => { + this.handshakeCompleted.off(onOpened); + this.closed.off(onClosed); + }; + + this.handshakeCompleted.on(onOpened); + this.closed.on(onClosed); + + return promise; + } + + /** + * Install the handler invoked for inbound commands. Without a handler the peer receives a `not_supported` + * error response. + * + * Throw {@link WsProxyCommandError} from the handler to select the error code reported to the peer. + */ + setCommandHandler(handler: WsProxyConnection.CommandHandler) { + this.#commandHandler = handler; + } + + /** + * Send a command and wait for the peer's response. + */ + async sendCommand(command: string, args?: Record): Promise | undefined> { + if (!this.connected) { + throw new WsProxyConnectionClosedError(`[${this.#id}] Cannot send command ${command}, not connected`); + } + + const id = this.#allocateCommandId(); + + const message: WsProxyCommandMessage = { id, command }; + if (args !== undefined) { + message.args = args; + } + + const { promise, resolver, rejecter } = createPromise | undefined>(); + this.#pendingCommands.set(id, { resolver, rejecter }); + + // Install the timeout wrapper before the first await so the pending promise never rejects unobserved + const response = withTimeout(this.#commandTimeout, promise, () => { + this.#pendingCommands.delete(id); + rejecter( + new PromiseTimeoutError( + `[${this.#id}] Command ${command} timed out after ${Duration.format(this.#commandTimeout)}`, + ), + ); + }); + + try { + await this.#write(JSON.stringify(message)); + } catch (error) { + if (this.#pendingCommands.delete(id)) { + rejecter(errorOf(error)); + } + } + + return response; + } + + sendEvent(event: string, data: Record) { + if (!this.connected) { + throw new WsProxyConnectionClosedError(`[${this.#id}] Cannot send event ${event}, not connected`); + } + + const message: WsProxyEventMessage = { event, data }; + this.#detach(this.#write(JSON.stringify(message))); + } + + sendFrame(opcode: number, handle: number, payload: Uint8Array) { + if (!this.connected) { + throw new WsProxyConnectionClosedError(`[${this.#id}] Cannot send frame ${opcode}, not connected`); + } + + if ( + !Number.isInteger(opcode) || + opcode < 0 || + opcode > 0xff || + !Number.isInteger(handle) || + handle < 0 || + handle > 0xffff + ) { + throw new ImplementationError(`[${this.#id}] Frame opcode ${opcode} or handle ${handle} is out of range`); + } + + logger.debug( + `[${this.#id}] [FRAME] -> opcode=${opcode} handle=${handle} len=${payload.length} head=${frameHead(payload)}`, + ); + this.#detach(this.#write(encodeWsProxyFrame(opcode, handle, payload))); + } + + /** + * Close the connection, rejecting pending commands. Safe to call repeatedly and before {@link start}. + */ + async close() { + this.#settleClosed(); + + if (this.#running === undefined) { + await this.#closeUnusedStreams(); + return; + } + + // Flush and close the outbound half first; cancelling the reader may take the whole transport down with it + await this.#releaseWriter(); + + const reader = this.#reader; + if (reader !== undefined) { + try { + await reader.cancel(); + } catch (error) { + logger.debug(`[${this.#id}] Error cancelling input:`, error); + } + } + + await this.#running; + } + + /** + * Own a promise we do not await elsewhere. Failures are diagnostic only; the connection state they reflect is + * already handled by {@link #write}. + */ + #detach(work: Promise) { + work.catch(error => logger.debug(`[${this.#id}] Background operation failed:`, error)); + } + + async #run(reader: ReadableStreamDefaultReader) { + try { + if (this.#role === "initiator") { + await this.#sendHello(); + } + await this.#readLoop(reader); + } catch (error) { + logger.debug(`[${this.#id}] Connection terminated:`, error); + } finally { + try { + await this.#terminate(); + } catch (error) { + logger.error(`[${this.#id}] Error closing connection:`, error); + } + } + } + + async #readLoop(reader: ReadableStreamDefaultReader) { + while (true) { + const { done, value } = await reader.read(); + if (done) { + logger.info(`[${this.#id}] Peer closed the connection`); + break; + } + + if (this.#handshakeComplete) { + this.#receive(value); + } else if (!(await this.#receiveHandshake(value))) { + break; + } + } + } + + async #sendHello() { + const hello: WsProxyHelloMessage = { type: "hello", version: this.#version, ...this.#hello }; + await this.#write(JSON.stringify(hello)); + } + + /** + * Process a message received before the handshake completes. Returns false if the connection must close. + */ + async #receiveHandshake(message: HttpEndpoint.WsMessage) { + if (this.#closedEmitted) { + return false; + } + + if (typeof message !== "string") { + logger.warn(`[${this.#id}] Received binary frame before handshake`); + return true; + } + + const parsed = this.#parse(message); + if (parsed === undefined) { + return true; + } + + return this.#role === "responder" ? this.#receiveHello(parsed) : this.#receiveHelloResponse(parsed); + } + + async #receiveHello(message: Record) { + if (message.type !== "hello") { + logger.warn(`[${this.#id}] Expected hello message, got: ${JSON.stringify(message)}`); + return false; + } + + this.#handshakeTimer?.stop(); + + const { version } = message; + if (version !== this.#version) { + logger.warn(`[${this.#id}] Peer protocol version ${String(version)} is not supported`); + const response: WsProxyHelloResponseMessage = { + type: "hello_response", + version: this.#version, + error: "unsupported_version", + message: `Server supports protocol version ${this.#version}, client sent version ${String(version)}`, + }; + await this.#write(JSON.stringify(response)); + return false; + } + + const response: WsProxyHelloResponseMessage = { type: "hello_response", version: this.#version }; + await this.#write(JSON.stringify(response)); + + return this.#completeHandshake(); + } + + #receiveHelloResponse(message: Record) { + if (message.type !== "hello_response") { + logger.warn(`[${this.#id}] Expected hello_response message, got: ${JSON.stringify(message)}`); + return false; + } + + this.#handshakeTimer?.stop(); + + if (message.error !== undefined) { + logger.error(`[${this.#id}] Peer rejected handshake: ${String(message.error)} ${String(message.message)}`); + return false; + } + + if (message.version !== this.#version) { + logger.error( + `[${this.#id}] Peer protocol version ${String(message.version)} does not match ${this.#version}`, + ); + return false; + } + + return this.#completeHandshake(); + } + + /** + * Commit the handshake. Returns false if the connection reached its terminal state while the response was in + * flight, in which case the peer's message arrives too late to open the connection. + */ + #completeHandshake() { + if (this.#closedEmitted) { + logger.debug(`[${this.#id}] Handshake completed after close, ignoring`); + return false; + } + + this.#handshakeComplete = true; + logger.info(`[${this.#id}] Handshake complete (version ${this.#version})`); + this.handshakeCompleted.emit(); + + return true; + } + + #receive(message: HttpEndpoint.WsMessage) { + if (this.#closedEmitted) { + return; + } + + if (typeof message !== "string") { + this.#receiveFrame(message); + return; + } + + const parsed = this.#parse(message); + if (parsed === undefined) { + return; + } + + if ("id" in parsed && "success" in parsed) { + this.#receiveResponse(parsed); + return; + } + + if ("id" in parsed && "command" in parsed) { + this.#receiveCommand(parsed); + return; + } + + if ("event" in parsed && "data" in parsed) { + const { event, data } = parsed; + if (typeof event === "string" && isRecord(data)) { + this.eventReceived.emit(event, data); + return; + } + } + + logger.warn(`[${this.#id}] Received unknown message:`, parsed); + } + + #receiveResponse(message: Record) { + const { id } = message; + if (typeof id !== "number") { + logger.warn(`[${this.#id}] Received response with invalid command id ${String(id)}`); + return; + } + + const pending = this.#pendingCommands.get(id); + if (pending === undefined) { + logger.warn(`[${this.#id}] Received response for unknown command id ${id}`); + return; + } + this.#pendingCommands.delete(id); + + if (message.success) { + pending.resolver(isRecord(message.result) ? message.result : undefined); + } else { + const code = typeof message.error === "string" ? message.error : "unknown_error"; + const detail = typeof message.message === "string" ? message.message : `Command ${id} failed`; + pending.rejecter(new WsProxyCommandError(code, detail)); + } + } + + /** + * Allocate a command ID, skipping IDs still in flight so a wrap cannot orphan a pending command. + */ + #allocateCommandId() { + if (this.#pendingCommands.size > 0xffff) { + throw new ImplementationError(`[${this.#id}] Too many commands in flight`); + } + + let id = this.#nextCommandId; + while (this.#pendingCommands.has(id)) { + id = (id + 1) & 0xffff; + } + this.#nextCommandId = (id + 1) & 0xffff; + + return id; + } + + #receiveCommand(message: Record) { + const { id, command } = message; + if (typeof id !== "number" || typeof command !== "string") { + logger.warn(`[${this.#id}] Received malformed command:`, message); + return; + } + + const handler = this.#commandHandler; + if (handler === undefined) { + // Leaving a request unanswered would stall the peer until its own command timeout + logger.warn(`[${this.#id}] Received command ${command} but no handler is installed`); + const response: WsProxyResponseMessage = { + id, + success: false, + error: "not_supported", + message: `Command ${command} is not supported`, + }; + this.#detach(this.#write(JSON.stringify(response))); + return; + } + + // Dispatch without blocking the read loop so a handler may itself exchange messages with the peer + this.#detach(this.#invokeCommand(handler, id, command, isRecord(message.args) ? message.args : undefined)); + } + + async #invokeCommand( + handler: WsProxyConnection.CommandHandler, + id: number, + command: string, + args?: Record, + ) { + let response: WsProxyResponseMessage; + + try { + const result = await handler(command, args); + response = { id, success: true }; + if (isRecord(result)) { + response.result = result; + } + } catch (cause) { + const error = errorOf(cause); + if (error instanceof WsProxyCommandError) { + response = { id, success: false, error: error.code, message: error.detail }; + } else { + logger.error(`[${this.#id}] Command ${command} failed:`, error); + response = { id, success: false, error: "internal_error", message: error.message }; + } + } + + await this.#write(JSON.stringify(response)); + } + + #receiveFrame(message: Exclude) { + let frame: WsProxyFrame; + try { + frame = decodeWsProxyFrame(Bytes.of(message)); + } catch (error) { + logger.warn(`[${this.#id}] Failed to decode binary frame:`, error); + return; + } + + logger.debug( + `[${this.#id}] [FRAME] <- opcode=${frame.opcode} handle=${frame.handle} len=${frame.payload.length} head=${frameHead(frame.payload)}`, + ); + this.frameReceived.emit(frame); + } + + #parse(message: string) { + let parsed: unknown; + try { + parsed = JSON.parse(message); + } catch (error) { + logger.warn(`[${this.#id}] Received invalid JSON:`, error); + return undefined; + } + + if (!isRecord(parsed)) { + logger.warn(`[${this.#id}] Received JSON message that is not an object`); + return undefined; + } + + return parsed; + } + + async #write(message: HttpEndpoint.WsMessage) { + const writer = this.#writer; + if (writer === undefined) { + throw new WsProxyConnectionClosedError(`[${this.#id}] Cannot write, connection is not open`); + } + + try { + await writer.write(message); + } catch (cause) { + const error = errorOf(cause); + this.#fail(error); + throw new WsProxyConnectionClosedError(`[${this.#id}] Write failed`, { cause: error }); + } + } + + /** + * Handle a transport failure detected on the outbound path. Cancelling the reader ends the read loop, which then + * performs stream teardown. + */ + #fail(error: Error) { + logger.debug(`[${this.#id}] Connection failed:`, error); + this.#settleClosed(); + + const reader = this.#reader; + if (reader !== undefined) { + this.#detach(reader.cancel()); + } + } + + #settleClosed() { + this.#handshakeTimer?.stop(); + this.#handshakeComplete = false; + + const pending = [...this.#pendingCommands.values()]; + this.#pendingCommands.clear(); + for (const { rejecter } of pending) { + rejecter(new WsProxyConnectionClosedError(`[${this.#id}] Peer disconnected`)); + } + + if (!this.#closedEmitted) { + this.#closedEmitted = true; + this.closed.emit(); + } + } + + async #terminate() { + this.#settleClosed(); + await this.#releaseWriter(); + await this.#releaseReader(); + } + + async #releaseWriter() { + const writer = this.#writer; + if (writer === undefined) { + return; + } + this.#writer = undefined; + + try { + await writer.close(); + } catch (error) { + logger.debug(`[${this.#id}] Error closing output:`, error); + } + + try { + writer.releaseLock(); + } catch (error) { + logger.debug(`[${this.#id}] Error releasing output:`, error); + } + } + + async #releaseReader() { + const reader = this.#reader; + if (reader === undefined) { + return; + } + this.#reader = undefined; + + try { + await reader.cancel(); + } catch (error) { + logger.debug(`[${this.#id}] Error cancelling input:`, error); + } + + try { + reader.releaseLock(); + } catch (error) { + logger.debug(`[${this.#id}] Error releasing input:`, error); + } + } + + /** + * Close a transport we never took ownership of, so abandoning a connection before {@link start} does not leak it. + */ + async #closeUnusedStreams() { + try { + await this.#connection.writable.close(); + } catch (error) { + logger.debug(`[${this.#id}] Error closing output:`, error); + } + + try { + await this.#connection.readable.cancel(); + } catch (error) { + logger.debug(`[${this.#id}] Error cancelling input:`, error); + } + } +} + +export namespace WsProxyConnection { + export type Role = "responder" | "initiator"; + + /** Additive hello fields. Version 1 peers ignore them. */ + export interface HelloFields { + role?: string; + features?: string[]; + } + + export type CommandHandler = ( + command: string, + args: Record | undefined, + ) => Promise | void>; + + export interface Options { + connection: HttpEndpoint.WsConnection; + + /** Protocol version we implement. */ + version: number; + + /** A responder waits for the peer's hello; an initiator sends one on {@link WsProxyConnection.start}. */ + role: Role; + + /** Prefix for the generated connection ID. Defaults to "wsp". */ + idPrefix?: string; + + /** Additive fields for the hello an initiator sends. */ + hello?: HelloFields; + + /** Time allowed for the handshake. Defaults to 10 seconds. */ + handshakeTimeout?: Duration; + + /** Time allowed for a command response. Defaults to 60 seconds. */ + commandTimeout?: Duration; + } + + export interface PendingCommand { + resolver: (result: Record | undefined) => void; + rejecter: (reason: unknown) => void; + } +} diff --git a/packages/general/src/net/ws-proxy/WsProxyFrame.ts b/packages/general/src/net/ws-proxy/WsProxyFrame.ts new file mode 100644 index 0000000000..050c1d7994 --- /dev/null +++ b/packages/general/src/net/ws-proxy/WsProxyFrame.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ImplementationError } from "#MatterError.js"; +import { NetworkError } from "../Network.js"; + +/** Framing error on a WS-proxy binary frame. */ +export class WsProxyFrameError extends NetworkError {} + +const HEADER_SIZE = 3; + +/** + * Binary WS-proxy frame: [1 byte opcode] [2 bytes handle big-endian] [N bytes payload]. + * Minimum size 3 bytes (empty payload). Layout is wire-compatible with BLE proxy protocol v1. + */ +export interface WsProxyFrame { + opcode: number; + handle: number; + payload: Uint8Array; +} + +export function encodeWsProxyFrame(opcode: number, handle: number, payload: Uint8Array): Uint8Array { + if (!Number.isInteger(opcode) || opcode < 0 || opcode > 0xff) { + throw new ImplementationError(`Binary frame opcode must be an integer from 0 to 255, got ${opcode}`); + } + if (!Number.isInteger(handle) || handle < 0 || handle > 0xffff) { + throw new ImplementationError(`Binary frame handle must be an integer from 0 to 65535, got ${handle}`); + } + + const frame = new Uint8Array(HEADER_SIZE + payload.length); + frame[0] = opcode; + frame[1] = handle >>> 8; + frame[2] = handle & 0xff; + frame.set(payload, HEADER_SIZE); + return frame; +} + +export function decodeWsProxyFrame(data: Uint8Array): WsProxyFrame { + if (data.length < HEADER_SIZE) { + throw new WsProxyFrameError(`Binary frame too short: ${data.length} bytes, minimum ${HEADER_SIZE}`); + } + return { + opcode: data[0], + handle: (data[1] << 8) | data[2], + payload: data.subarray(HEADER_SIZE), + }; +} diff --git a/packages/general/src/net/ws-proxy/WsProxyMessage.ts b/packages/general/src/net/ws-proxy/WsProxyMessage.ts new file mode 100644 index 0000000000..22e6657754 --- /dev/null +++ b/packages/general/src/net/ws-proxy/WsProxyMessage.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NetworkError } from "../Network.js"; + +/** Peer rejected a command with an error response; `code` is the wire error code. */ +export class WsProxyCommandError extends NetworkError { + /** The description as it appears in the wire `message` field, without the {@link code} prefix. */ + readonly detail: string; + + constructor( + readonly code: string, + message: string, + options?: ErrorOptions, + ) { + super(`${code}: ${message}`, options); + this.detail = message; + } +} + +/** Connection closed while commands were pending or before use. */ +export class WsProxyConnectionClosedError extends NetworkError {} + +export interface WsProxyHelloMessage { + type: "hello"; + version: number; + + /** Optional additive extension: declared role of the dialing peer. v1 peers omit it. */ + role?: string; + + /** Optional additive extension: feature flags. v1 peers omit it. */ + features?: string[]; +} + +export interface WsProxyHelloResponseMessage { + type: "hello_response"; + version: number; + error?: string; + message?: string; +} + +export interface WsProxyCommandMessage { + id: number; + command: string; + args?: Record; +} + +export interface WsProxySuccessResponse { + id: number; + success: true; + result?: Record; +} + +export interface WsProxyErrorResponse { + id: number; + success: false; + error: string; + message: string; +} + +export type WsProxyResponseMessage = WsProxySuccessResponse | WsProxyErrorResponse; + +export interface WsProxyEventMessage { + event: string; + data: Record; +} diff --git a/packages/general/src/net/ws-proxy/index.ts b/packages/general/src/net/ws-proxy/index.ts new file mode 100644 index 0000000000..49ece506cc --- /dev/null +++ b/packages/general/src/net/ws-proxy/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./WsProxyConnection.js"; +export * from "./WsProxyFrame.js"; +export * from "./WsProxyMessage.js"; diff --git a/packages/general/test/net/ws-proxy/WsProxyConnectionTest.ts b/packages/general/test/net/ws-proxy/WsProxyConnectionTest.ts new file mode 100644 index 0000000000..1167eebdfd --- /dev/null +++ b/packages/general/test/net/ws-proxy/WsProxyConnectionTest.ts @@ -0,0 +1,994 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +// Installs the platform Time implementation; no other module in this test's import graph does +import "#index.js"; + +import { ImplementationError } from "#MatterError.js"; +import type { HttpEndpoint } from "#net/http/HttpEndpoint.js"; +import { MockWsConnection } from "#net/http/MockWsConnection.js"; +import { NetworkError } from "#net/Network.js"; +import { WsProxyConnection } from "#net/ws-proxy/WsProxyConnection.js"; +import { decodeWsProxyFrame, encodeWsProxyFrame, type WsProxyFrame } from "#net/ws-proxy/WsProxyFrame.js"; +import { WsProxyCommandError, WsProxyConnectionClosedError } from "#net/ws-proxy/WsProxyMessage.js"; +import { Millis, Seconds } from "#time/TimeUnit.js"; +import { Bytes } from "#util/Bytes.js"; +import type { Observable } from "#util/Observable.js"; +import { PromiseTimeoutError } from "#util/Promises.js"; + +const VERSION = 1; + +const { send, receive } = MockWsConnection; + +async function receiveFrame(connection: HttpEndpoint.WsConnection) { + const reader = connection.readable.getReader(); + try { + const { value } = await reader.read(); + if (value === undefined || typeof value === "string") { + throw new NetworkError(`Expected a binary message but received ${typeof value}`); + } + return decodeWsProxyFrame(Bytes.of(value)); + } finally { + reader.releaseLock(); + } +} + +/** + * Assert a settled value is an error of the expected type and narrow it for further assertions. + */ +function errorOfType(value: unknown, type: new (...args: never[]) => T) { + expect(value).instanceOf(type); + if (!(value instanceof type)) { + throw new NetworkError(`Expected ${type.name}`); + } + return value; +} + +async function sendBytes(connection: HttpEndpoint.WsConnection, bytes: Uint8Array) { + const writer = connection.writable.getWriter(); + try { + await writer.write(bytes); + } finally { + writer.releaseLock(); + } +} + +async function sendRaw(connection: HttpEndpoint.WsConnection, message: string) { + const writer = connection.writable.getWriter(); + try { + await writer.write(message); + } finally { + writer.releaseLock(); + } +} + +async function expectEnd(connection: HttpEndpoint.WsConnection) { + const reader = connection.readable.getReader(); + try { + expect((await reader.read()).done).true; + } finally { + reader.releaseLock(); + } +} + +function nextEmit(observable: Observable<[]>) { + return new Promise(resolve => observable.on(() => resolve())); +} + +function settlement(promise: Promise) { + return promise.then( + () => undefined, + (error: unknown) => error, + ); +} + +/** + * A connection whose inbound stream can be errored and whose outbound stream can be made to fail, which + * {@link MockWsConnection} cannot do but a real WebSocket does. + */ +function faultyConnection() { + let inbound: ReadableStreamDefaultController; + let writesFail = false; + const written = new Array(); + + const connection: HttpEndpoint.WsConnection = { + readable: new ReadableStream({ + start(controller) { + inbound = controller; + }, + }), + + writable: new WritableStream({ + write(chunk) { + if (writesFail) { + throw new NetworkError("Simulated transport failure"); + } + written.push(chunk); + }, + }), + }; + + return { + connection, + written, + deliver: (message: object) => inbound.enqueue(JSON.stringify(message)), + breakInbound: () => inbound.error(new NetworkError("Simulated inbound failure")), + breakOutbound: () => (writesFail = true), + }; +} + +/** + * Create a responder that has completed its handshake, plus the far side of the connection. + */ +async function connectResponder(options?: Partial) { + const { client, server } = MockWsConnection(); + const connection = new WsProxyConnection({ + connection: server, + version: VERSION, + role: "responder", + ...options, + }); + connection.start(); + + await send(client, { type: "hello", version: VERSION }); + expect(await receive(client)).deep.equals({ type: "hello_response", version: VERSION }); + expect(connection.connected).true; + + return { client, connection }; +} + +describe("WsProxyConnection", () => { + before(() => MockTime.enable()); + + describe("handshake", () => { + it("completes the responder handshake", async () => { + const { client, server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + const completed = nextEmit(connection.handshakeCompleted); + + connection.start(); + expect(connection.connected).false; + + await send(client, { type: "hello", version: VERSION }); + + expect(await receive(client)).deep.equals({ type: "hello_response", version: VERSION }); + await completed; + expect(connection.connected).true; + + await connection.close(); + }); + + it("rejects an unsupported responder version", async () => { + const { client, server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + const closed = nextEmit(connection.closed); + + connection.start(); + await send(client, { type: "hello", version: 99 }); + + expect(await receive(client)).deep.equals({ + type: "hello_response", + version: VERSION, + error: "unsupported_version", + message: "Server supports protocol version 1, client sent version 99", + }); + + await closed; + expect(connection.connected).false; + await expectEnd(client); + }); + + it("closes when the first responder message is not a hello", async () => { + const { client, server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + const closed = nextEmit(connection.closed); + + connection.start(); + await send(client, { type: "something-else" }); + + await closed; + expect(connection.connected).false; + await expectEnd(client); + }); + + it("completes the initiator handshake", async () => { + const { client, server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: client, version: VERSION, role: "initiator" }); + const completed = nextEmit(connection.handshakeCompleted); + + connection.start(); + + expect(await receive(server)).deep.equals({ type: "hello", version: VERSION }); + + await send(server, { type: "hello_response", version: VERSION }); + await completed; + expect(connection.connected).true; + + await connection.close(); + }); + + it("includes additive hello fields and omits absent ones", async () => { + const { client, server } = MockWsConnection(); + const connection = new WsProxyConnection({ + connection: client, + version: VERSION, + role: "initiator", + hello: { role: "provider" }, + }); + + connection.start(); + + const hello = await receive(server); + expect(hello).deep.equals({ type: "hello", version: VERSION, role: "provider" }); + expect("features" in hello).false; + + await connection.close(); + }); + + it("closes when the initiator hello is rejected", async () => { + const { client, server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: client, version: VERSION, role: "initiator" }); + const closed = nextEmit(connection.closed); + + connection.start(); + expect(await receive(server)).deep.equals({ type: "hello", version: VERSION }); + + await send(server, { + type: "hello_response", + version: VERSION, + error: "unsupported_version", + message: "nope", + }); + + await closed; + expect(connection.connected).false; + + await connection.close(); + }); + + it("ignores a hello response that arrives after the connection closed", async () => { + const faulty = faultyConnection(); + const connection = new WsProxyConnection({ + connection: faulty.connection, + version: VERSION, + role: "initiator", + }); + + let handshakeCompleted = false; + connection.handshakeCompleted.on(() => { + handshakeCompleted = true; + }); + + connection.start(); + + // Park the read loop on the reader by waiting until our hello is on the wire + while (faulty.written.length === 0) { + await MockTime.yield(); + } + await MockTime.yield(); + await MockTime.yield(); + + // The peer's response was already in flight when we gave up on the handshake + const closing = connection.close(); + faulty.deliver({ type: "hello_response", version: VERSION }); + await closing; + + expect(handshakeCompleted).false; + expect(connection.connected).false; + }); + + it("closes when the handshake times out", async () => { + const { client, server } = MockWsConnection(); + const connection = new WsProxyConnection({ + connection: server, + version: VERSION, + role: "responder", + handshakeTimeout: Seconds(1), + }); + const closed = nextEmit(connection.closed); + + connection.start(); + await MockTime.advance(Seconds(1)); + + await closed; + expect(connection.connected).false; + await expectEnd(client); + }); + }); + + describe("opened", () => { + it("resolves when the handshake completes", async () => { + const { client, server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + + connection.start(); + const opened = connection.opened(); + + await send(client, { type: "hello", version: VERSION }); + await opened; + + expect(connection.connected).true; + + await connection.close(); + }); + + it("resolves immediately when already open", async () => { + const { connection } = await connectResponder(); + + await connection.opened(); + + await connection.close(); + }); + + it("rejects when the connection closes first", async () => { + const { client, server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + + connection.start(); + const opened = settlement(connection.opened()); + + await send(client, { type: "something-else" }); + + errorOfType(await opened, WsProxyConnectionClosedError); + + await connection.close(); + }); + + it("rejects immediately when already closed", async () => { + const { server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + + await connection.close(); + + await expect(connection.opened()).rejectedWith(WsProxyConnectionClosedError); + }); + + it("stops observing once settled", async () => { + const { client, server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + + connection.start(); + const opened = connection.opened(); + expect(connection.closed.isObserved).true; + + await send(client, { type: "hello", version: VERSION }); + await opened; + + expect(connection.closed.isObserved).false; + expect(connection.handshakeCompleted.isObserved).false; + + await connection.close(); + }); + }); + + describe("commands", () => { + it("resolves a command with its result", async () => { + const { client, connection } = await connectResponder(); + + const result = connection.sendCommand("ping", { a: 1 }); + + expect(await receive(client)).deep.equals({ id: 0, command: "ping", args: { a: 1 } }); + await send(client, { id: 0, success: true, result: { b: 2 } }); + + expect(await result).deep.equals({ b: 2 }); + + await connection.close(); + }); + + it("increments command ids and omits absent args", async () => { + const { client, connection } = await connectResponder(); + + const first = connection.sendCommand("x"); + expect(await receive(client)).deep.equals({ id: 0, command: "x" }); + await send(client, { id: 0, success: true }); + expect(await first).undefined; + + const second = connection.sendCommand("y"); + expect(await receive(client)).deep.equals({ id: 1, command: "y" }); + await send(client, { id: 1, success: true }); + await second; + + await connection.close(); + }); + + it("rejects a command with an error response", async () => { + const { client, connection } = await connectResponder(); + + const result = settlement(connection.sendCommand("boom")); + expect(await receive(client)).deep.equals({ id: 0, command: "boom" }); + await send(client, { id: 0, success: false, error: "not_connected", message: "no peripheral" }); + + const error = errorOfType(await result, WsProxyCommandError); + expect(error.code).equals("not_connected"); + expect(error.message).equals("not_connected: no peripheral"); + + await connection.close(); + }); + + it("rejects and forgets a command that times out", async () => { + const { client, connection } = await connectResponder({ commandTimeout: Millis(500) }); + + const timedOut = settlement(connection.sendCommand("slow")); + expect(await receive(client)).deep.equals({ id: 0, command: "slow" }); + + await MockTime.advance(Millis(500)); + errorOfType(await timedOut, PromiseTimeoutError); + + // The pending command is forgotten so the late response is ignored, and the connection remains usable + await send(client, { id: 0, success: true, result: { late: true } }); + + const next = connection.sendCommand("after"); + expect(await receive(client)).deep.equals({ id: 1, command: "after" }); + await send(client, { id: 1, success: true, result: { ok: true } }); + expect(await next).deep.equals({ ok: true }); + + await connection.close(); + }); + + it("ignores a response for an unknown command id", async () => { + const { client, connection } = await connectResponder(); + + await send(client, { id: 42, success: true, result: {} }); + + const result = connection.sendCommand("still-alive"); + expect(await receive(client)).deep.equals({ id: 0, command: "still-alive" }); + await send(client, { id: 0, success: true }); + await result; + + await connection.close(); + }); + + it("throws when sending a command while not connected", async () => { + const { server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + + await expect(connection.sendCommand("x")).rejectedWith(WsProxyConnectionClosedError); + + await connection.close(); + }); + + it("correlates concurrent commands answered out of order", async () => { + const { client, connection } = await connectResponder(); + + const first = connection.sendCommand("a"); + const second = settlement(connection.sendCommand("b")); + const third = connection.sendCommand("c"); + + expect(await receive(client)).deep.equals({ id: 0, command: "a" }); + expect(await receive(client)).deep.equals({ id: 1, command: "b" }); + expect(await receive(client)).deep.equals({ id: 2, command: "c" }); + + await send(client, { id: 2, success: true, result: { n: 2 } }); + await send(client, { id: 0, success: true, result: { n: 0 } }); + await send(client, { id: 1, success: false, error: "bad", message: "nope" }); + + expect(await first).deep.equals({ n: 0 }); + expect(await third).deep.equals({ n: 2 }); + expect(await second).instanceOf(WsProxyCommandError); + + await connection.close(); + }); + + it("rejects an error response that carries no code", async () => { + const { client, connection } = await connectResponder(); + + const result = settlement(connection.sendCommand("boom")); + expect(await receive(client)).deep.equals({ id: 0, command: "boom" }); + await send(client, { id: 0, success: false }); + + const error = errorOfType(await result, WsProxyCommandError); + expect(error.code).equals("unknown_error"); + + await connection.close(); + }); + + it("refuses to send anything before the handshake completes", async () => { + const { client, server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + connection.start(); + + await expect(connection.sendCommand("x")).rejectedWith(WsProxyConnectionClosedError); + expect(() => connection.sendEvent("e", {})).throws(WsProxyConnectionClosedError); + expect(() => connection.sendFrame(1, 1, new Uint8Array(0))).throws(WsProxyConnectionClosedError); + + await send(client, { type: "hello", version: VERSION }); + + // Nothing reached the wire ahead of the hello response + expect(await receive(client)).deep.equals({ type: "hello_response", version: VERSION }); + + await connection.close(); + }); + + it("rejects pending commands when the connection closes", async () => { + const { client, connection } = await connectResponder(); + + const result = connection.sendCommand("pending"); + expect(await receive(client)).deep.equals({ id: 0, command: "pending" }); + + await connection.close(); + + await expect(result).rejectedWith(WsProxyConnectionClosedError); + }); + + it("emits closed exactly once across repeated closes", async () => { + const { connection } = await connectResponder(); + + let closeCount = 0; + connection.closed.on(() => { + closeCount++; + }); + + await connection.close(); + await connection.close(); + + expect(closeCount).equals(1); + expect(connection.connected).false; + }); + }); + + describe("command handler", () => { + it("answers an inbound command with a result", async () => { + const { client, connection } = await connectResponder(); + + const invocations = new Array<[string, Record | undefined]>(); + connection.setCommandHandler(async (command, args) => { + invocations.push([command, args]); + return { b: 2 }; + }); + + await send(client, { id: 5, command: "ping", args: { a: 1 } }); + + expect(await receive(client)).deep.equals({ id: 5, success: true, result: { b: 2 } }); + expect(invocations).deep.equals([["ping", { a: 1 }]]); + + await connection.close(); + }); + + it("answers with the wire code of a WsProxyCommandError", async () => { + const { client, connection } = await connectResponder(); + + connection.setCommandHandler(async () => { + throw new WsProxyCommandError("not_connected", "nope"); + }); + + await send(client, { id: 5, command: "ping" }); + + expect(await receive(client)).deep.equals({ + id: 5, + success: false, + error: "not_connected", + message: "nope", + }); + + await connection.close(); + }); + + it("answers with internal_error for an unexpected handler failure", async () => { + const { client, connection } = await connectResponder(); + + connection.setCommandHandler(async () => { + throw new WsProxyConnectionClosedError("kaboom"); + }); + + await send(client, { id: 7, command: "ping" }); + + expect(await receive(client)).deep.equals({ + id: 7, + success: false, + error: "internal_error", + message: "kaboom", + }); + + await connection.close(); + }); + + it("answers a handler that returns no result", async () => { + const { client, connection } = await connectResponder(); + + connection.setCommandHandler(async () => {}); + + await send(client, { id: 8, command: "ping" }); + + expect(await receive(client)).deep.equals({ id: 8, success: true }); + + await connection.close(); + }); + + it("answers with not_supported when no handler is installed", async () => { + const { client, connection } = await connectResponder(); + + await send(client, { id: 3, command: "ping" }); + + expect(await receive(client)).deep.equals({ + id: 3, + success: false, + error: "not_supported", + message: "Command ping is not supported", + }); + + await connection.close(); + }); + + it("supports a handler that issues its own command", async () => { + const { client, connection } = await connectResponder(); + + connection.setCommandHandler(async command => { + expect(command).equals("outer"); + const result = await connection.sendCommand("inner"); + return { echoed: result?.value }; + }); + + await send(client, { id: 9, command: "outer" }); + + expect(await receive(client)).deep.equals({ id: 0, command: "inner" }); + await send(client, { id: 0, success: true, result: { value: 7 } }); + + expect(await receive(client)).deep.equals({ id: 9, success: true, result: { echoed: 7 } }); + + await connection.close(); + }); + + it("keeps serving commands while a handler is in flight", async () => { + const { client, connection } = await connectResponder(); + + let release: (() => void) | undefined; + const blocked = new Promise(resolve => (release = resolve)); + + connection.setCommandHandler(async command => { + if (command === "slow") { + await blocked; + return { slow: true }; + } + return { fast: true }; + }); + + await send(client, { id: 1, command: "slow" }); + await send(client, { id: 2, command: "fast" }); + + expect(await receive(client)).deep.equals({ id: 2, success: true, result: { fast: true } }); + + release?.(); + expect(await receive(client)).deep.equals({ id: 1, success: true, result: { slow: true } }); + + await connection.close(); + }); + }); + + describe("events", () => { + it("sends events", async () => { + const { client, connection } = await connectResponder(); + + connection.sendEvent("scanResult", { address: "aa:bb" }); + + expect(await receive(client)).deep.equals({ event: "scanResult", data: { address: "aa:bb" } }); + + await connection.close(); + }); + + it("receives events", async () => { + const { client, connection } = await connectResponder(); + + const received = new Array<[string, Record]>(); + const first = new Promise(resolve => + connection.eventReceived.on((event, data) => { + received.push([event, data]); + resolve(); + }), + ); + + await send(client, { event: "disconnected", data: { handle: 3 } }); + await first; + + expect(received).deep.equals([["disconnected", { handle: 3 }]]); + + await connection.close(); + }); + + it("throws when sending an event while not connected", async () => { + const { server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + + expect(() => connection.sendEvent("x", {})).throws(WsProxyConnectionClosedError); + + await connection.close(); + }); + + it("ignores malformed JSON", async () => { + const { client, connection } = await connectResponder(); + + await sendRaw(client, "{not json"); + + const result = connection.sendCommand("still-alive"); + expect(await receive(client)).deep.equals({ id: 0, command: "still-alive" }); + await send(client, { id: 0, success: true }); + await result; + + await connection.close(); + }); + }); + + describe("frames", () => { + it("sends binary frames", async () => { + const { client, connection } = await connectResponder(); + + connection.sendFrame(2, 42, new Uint8Array([0xaa, 0xbb])); + + const frame = await receiveFrame(client); + expect(frame.opcode).equals(2); + expect(frame.handle).equals(42); + expect(Array.from(frame.payload)).deep.equals([0xaa, 0xbb]); + + await connection.close(); + }); + + it("receives binary frames", async () => { + const { client, connection } = await connectResponder(); + + const frames = new Array(); + const first = new Promise(resolve => + connection.frameReceived.on(frame => { + frames.push(frame); + resolve(); + }), + ); + + await sendBytes(client, encodeWsProxyFrame(3, 7, new Uint8Array([0x01, 0x02]))); + await first; + + expect(frames).length(1); + expect(frames[0].opcode).equals(3); + expect(frames[0].handle).equals(7); + expect(Array.from(frames[0].payload)).deep.equals([0x01, 0x02]); + + await connection.close(); + }); + + it("ignores binary frames received before the handshake", async () => { + const { client, server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + + const frames = new Array(); + connection.frameReceived.on(frame => { + frames.push(frame); + }); + + connection.start(); + await sendBytes(client, encodeWsProxyFrame(1, 1, new Uint8Array([0xff]))); + + // The connection stays in handshake state and still accepts the hello + await send(client, { type: "hello", version: VERSION }); + expect(await receive(client)).deep.equals({ type: "hello_response", version: VERSION }); + + expect(frames).length(0); + + await connection.close(); + }); + + it("ignores undecodable binary frames", async () => { + const { client, connection } = await connectResponder(); + + const frames = new Array(); + const decoded = new Promise(resolve => + connection.frameReceived.on(frame => { + frames.push(frame); + resolve(); + }), + ); + + await sendBytes(client, new Uint8Array([0x01])); + await sendBytes(client, encodeWsProxyFrame(4, 4, new Uint8Array([0x05]))); + await decoded; + + expect(frames).length(1); + expect(frames[0].opcode).equals(4); + + await connection.close(); + }); + + it("rejects an out-of-range or non-integer opcode or handle", async () => { + const { connection } = await connectResponder(); + + expect(() => connection.sendFrame(0x100, 1, new Uint8Array(0))).throws(ImplementationError); + expect(() => connection.sendFrame(1, 0x10000, new Uint8Array(0))).throws(ImplementationError); + expect(() => connection.sendFrame(1, -1, new Uint8Array(0))).throws(ImplementationError); + expect(() => connection.sendFrame(1, 1.5, new Uint8Array(0))).throws(ImplementationError); + expect(() => connection.sendFrame(1, Number.NaN, new Uint8Array(0))).throws(ImplementationError); + expect(() => connection.sendFrame(Number.NaN, 1, new Uint8Array(0))).throws(ImplementationError); + + await connection.close(); + + // A closed connection reports the closure, not the range + expect(() => connection.sendFrame(0x100, 1, new Uint8Array(0))).throws(WsProxyConnectionClosedError); + }); + + it("throws when sending a frame while not connected", async () => { + const { server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + + expect(() => connection.sendFrame(1, 1, new Uint8Array(0))).throws(WsProxyConnectionClosedError); + + await connection.close(); + }); + }); + + describe("lifecycle", () => { + it("closes when the peer ends the stream", async () => { + const { client, connection } = await connectResponder(); + const closed = nextEmit(connection.closed); + + await client.writable.close(); + + await closed; + expect(connection.connected).false; + + await connection.close(); + }); + + it("survives an observer that throws", async () => { + const { client, connection } = await connectResponder(); + + connection.eventReceived.on(() => { + throw new NetworkError("Observer failure"); + }); + + await send(client, { event: "boom", data: {} }); + + const result = connection.sendCommand("still-alive"); + expect(await receive(client)).deep.equals({ id: 0, command: "still-alive" }); + await send(client, { id: 0, success: true }); + await result; + + expect(connection.connected).true; + + await connection.close(); + }); + + it("closes despite a closed observer that throws", async () => { + const { connection } = await connectResponder(); + + connection.closed.on(() => { + throw new NetworkError("Observer failure"); + }); + + await connection.close(); + + expect(connection.connected).false; + }); + + it("ignores traffic that arrives after the connection closed", async () => { + const faulty = faultyConnection(); + const connection = new WsProxyConnection({ + connection: faulty.connection, + version: VERSION, + role: "responder", + }); + + const events = new Array(); + connection.eventReceived.on(event => { + events.push(event); + }); + + connection.start(); + faulty.deliver({ type: "hello", version: VERSION }); + await connection.opened(); + + // Park the read loop on the reader + while (faulty.written.length === 0) { + await MockTime.yield(); + } + await MockTime.yield(); + await MockTime.yield(); + + const closing = connection.close(); + faulty.deliver({ event: "late", data: {} }); + await closing; + + expect(events).deep.equals([]); + }); + + it("tolerates overlapping closes", async () => { + const { connection } = await connectResponder(); + + let closeCount = 0; + connection.closed.on(() => { + closeCount++; + }); + + await Promise.all([connection.close(), connection.close()]); + + expect(closeCount).equals(1); + }); + + it("closes the transport when closed before start", async () => { + const { client, server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + + await connection.close(); + + await expectEnd(client); + }); + + it("refuses to start a closed connection", async () => { + const { server } = MockWsConnection(); + const connection = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + + await connection.close(); + + expect(() => connection.start()).throws(ImplementationError); + }); + + it("refuses to start twice", async () => { + const { connection } = await connectResponder(); + + expect(() => connection.start()).throws(ImplementationError); + + await connection.close(); + }); + + it("tears down when the inbound stream errors", async () => { + const faulty = faultyConnection(); + const connection = new WsProxyConnection({ + connection: faulty.connection, + version: VERSION, + role: "responder", + }); + const completed = nextEmit(connection.handshakeCompleted); + const closed = nextEmit(connection.closed); + + connection.start(); + faulty.deliver({ type: "hello", version: VERSION }); + await completed; + + const pending = settlement(connection.sendCommand("x")); + faulty.breakInbound(); + + await closed; + expect(await pending).instanceOf(WsProxyConnectionClosedError); + expect(connection.connected).false; + + await connection.close(); + }); + + it("tears down when an outbound write fails", async () => { + const faulty = faultyConnection(); + const connection = new WsProxyConnection({ + connection: faulty.connection, + version: VERSION, + role: "responder", + }); + const completed = nextEmit(connection.handshakeCompleted); + const closed = nextEmit(connection.closed); + + connection.start(); + faulty.deliver({ type: "hello", version: VERSION }); + await completed; + + faulty.breakOutbound(); + const failed = settlement(connection.sendCommand("x")); + + expect(await failed).instanceOf(WsProxyConnectionClosedError); + await closed; + expect(connection.connected).false; + + await connection.close(); + }); + + it("assigns distinct ids with the configured prefix", async () => { + const { client, server } = MockWsConnection(); + const first = new WsProxyConnection({ connection: server, version: VERSION, role: "responder" }); + const second = new WsProxyConnection({ + connection: client, + version: VERSION, + role: "initiator", + idPrefix: "ble", + }); + + expect(first.id).match(/^wsp[0-9a-f]+$/); + expect(second.id).match(/^ble[0-9a-f]+$/); + expect(first.id.slice(3)).not.equals(second.id.slice(3)); + }); + }); +}); diff --git a/packages/general/test/net/ws-proxy/WsProxyFrameTest.ts b/packages/general/test/net/ws-proxy/WsProxyFrameTest.ts new file mode 100644 index 0000000000..23a2082016 --- /dev/null +++ b/packages/general/test/net/ws-proxy/WsProxyFrameTest.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ImplementationError } from "#MatterError.js"; +import { decodeWsProxyFrame, encodeWsProxyFrame, WsProxyFrameError } from "#net/ws-proxy/WsProxyFrame.js"; + +describe("WsProxyFrame", () => { + describe("encodeWsProxyFrame / decodeWsProxyFrame", () => { + it("should encode and decode an opcode 1 frame", () => { + const payload = new Uint8Array([0x01, 0x02, 0x03, 0x04]); + const encoded = encodeWsProxyFrame(0x01, 1, payload); + + expect(encoded.length).to.equal(7); + expect(encoded[0]).to.equal(0x01); + expect(encoded[1]).to.equal(0x00); + expect(encoded[2]).to.equal(0x01); + + const decoded = decodeWsProxyFrame(encoded); + expect(decoded.opcode).to.equal(0x01); + expect(decoded.handle).to.equal(1); + expect(decoded.payload).to.deep.equal(payload); + }); + + it("should encode and decode an opcode 2 frame", () => { + const payload = new Uint8Array([0x65, 0x6c, 0x04, 0xf4, 0x00, 0x06]); + const encoded = encodeWsProxyFrame(0x02, 42, payload); + const decoded = decodeWsProxyFrame(encoded); + + expect(decoded.opcode).to.equal(0x02); + expect(decoded.handle).to.equal(42); + expect(decoded.payload).to.deep.equal(payload); + }); + + it("should encode and decode an opcode 3 frame", () => { + const payload = new Uint8Array([0xaa, 0xbb]); + const encoded = encodeWsProxyFrame(0x03, 100, payload); + const decoded = decodeWsProxyFrame(encoded); + + expect(decoded.opcode).to.equal(0x03); + expect(decoded.handle).to.equal(100); + expect(decoded.payload).to.deep.equal(payload); + }); + + it("should handle empty payload", () => { + const payload = new Uint8Array(0); + const encoded = encodeWsProxyFrame(0x01, 1, payload); + + expect(encoded.length).to.equal(3); + + const decoded = decodeWsProxyFrame(encoded); + expect(decoded.opcode).to.equal(0x01); + expect(decoded.handle).to.equal(1); + expect(decoded.payload.length).to.equal(0); + }); + + it("should handle max handle (0xFFFF)", () => { + const payload = new Uint8Array([0x01]); + const encoded = encodeWsProxyFrame(0x02, 0xffff, payload); + + expect(encoded[1]).to.equal(0xff); + expect(encoded[2]).to.equal(0xff); + + const decoded = decodeWsProxyFrame(encoded); + expect(decoded.handle).to.equal(0xffff); + }); + + it("should handle handle 0", () => { + const payload = new Uint8Array([0x01]); + const encoded = encodeWsProxyFrame(0x01, 0, payload); + + expect(encoded[1]).to.equal(0x00); + expect(encoded[2]).to.equal(0x00); + + const decoded = decodeWsProxyFrame(encoded); + expect(decoded.handle).to.equal(0); + }); + + it("should accept the maximum opcode", () => { + const encoded = encodeWsProxyFrame(0xff, 0, new Uint8Array(0)); + expect(decodeWsProxyFrame(encoded).opcode).to.equal(0xff); + }); + + it("should throw on an opcode outside a byte", () => { + expect(() => encodeWsProxyFrame(0x100, 1, new Uint8Array(0))).to.throw( + ImplementationError, + "Binary frame opcode must be an integer from 0 to 255", + ); + expect(() => encodeWsProxyFrame(-1, 1, new Uint8Array(0))).to.throw( + ImplementationError, + "Binary frame opcode must be an integer from 0 to 255", + ); + }); + + it("should throw on a handle outside two bytes", () => { + expect(() => encodeWsProxyFrame(0x01, 0x10000, new Uint8Array(0))).to.throw( + ImplementationError, + "Binary frame handle must be an integer from 0 to 65535", + ); + expect(() => encodeWsProxyFrame(0x01, -1, new Uint8Array(0))).to.throw( + ImplementationError, + "Binary frame handle must be an integer from 0 to 65535", + ); + }); + + it("should throw on a non-integer opcode or handle", () => { + expect(() => encodeWsProxyFrame(1.5, 1, new Uint8Array(0))).to.throw(ImplementationError); + expect(() => encodeWsProxyFrame(Number.NaN, 1, new Uint8Array(0))).to.throw(ImplementationError); + expect(() => encodeWsProxyFrame(0x01, 1.5, new Uint8Array(0))).to.throw(ImplementationError); + expect(() => encodeWsProxyFrame(0x01, Number.NaN, new Uint8Array(0))).to.throw(ImplementationError); + expect(() => encodeWsProxyFrame(0x01, Number.POSITIVE_INFINITY, new Uint8Array(0))).to.throw( + ImplementationError, + ); + }); + + it("should throw on frame too short", () => { + expect(() => decodeWsProxyFrame(new Uint8Array(2))).to.throw(WsProxyFrameError, "Binary frame too short"); + expect(() => decodeWsProxyFrame(new Uint8Array(1))).to.throw(WsProxyFrameError, "Binary frame too short"); + expect(() => decodeWsProxyFrame(new Uint8Array(0))).to.throw(WsProxyFrameError, "Binary frame too short"); + }); + + it("should handle large payload", () => { + const payload = new Uint8Array(1024); + payload.fill(0x42); + const encoded = encodeWsProxyFrame(0x01, 5, payload); + const decoded = decodeWsProxyFrame(encoded); + + expect(decoded.payload.length).to.equal(1024); + expect(decoded.payload[0]).to.equal(0x42); + expect(decoded.payload[1023]).to.equal(0x42); + }); + + it("should preserve big-endian handle encoding", () => { + const encoded = encodeWsProxyFrame(0x01, 0x0102, new Uint8Array(0)); + expect(encoded[1]).to.equal(0x01); + expect(encoded[2]).to.equal(0x02); + }); + + it("should decode a hand-written wire buffer as big-endian", () => { + const decoded = decodeWsProxyFrame(new Uint8Array([0x02, 0x01, 0x02, 0xaa])); + + expect(decoded.opcode).to.equal(0x02); + expect(decoded.handle).to.equal(0x0102); + expect(Array.from(decoded.payload)).to.deep.equal([0xaa]); + }); + }); +}); diff --git a/packages/ws-ble/README.md b/packages/ws-ble/README.md new file mode 100644 index 0000000000..afc5171e59 --- /dev/null +++ b/packages/ws-ble/README.md @@ -0,0 +1,138 @@ +# @matter/ws-ble - BLE-over-WebSocket proxy for matter.js + +This is a matter.js plugin that proxies BLE GATT access for Matter commissioning over a +WebSocket connection, letting a matter.js controller reach BLE peripherals attached to a remote +host. The wire protocol (v1) is wire-compatible with existing matterjs-server deployments, +including its Python reference proxy client. + +## Security + +**The proxy endpoint is unauthenticated by design.** Any peer that can reach it gains BLE radio +access on every proxy client connected to the hub — scanning, connecting, and reading/writing +GATT characteristics. `BleProxyHandler` and `WsProxyConnection` perform no authentication of their +own. + +The embedder that hosts the WebSocket endpoint is responsible for securing it: put +authentication in front of the upgrade, isolate the network it is reachable on, or front it with +a reverse proxy. If you run the `matter-ble-proxy` reference client, never point it at a hub port +that is exposed without one of these protections. + +## Architecture + +The **hub** is the WebSocket server side. It runs next to the matter.js controller (for example +inside matterjs-server) and exposes a `/ble` WebSocket endpoint. `BleProxyHandler` accepts any +number of proxy client connections there, and `ProxyBle` (a `Ble` implementation) lets the +controller drive BLE scanning and GATT operations through it as if the hardware were local. + +A **proxy client** dials in from wherever the actual Bluetooth adapter lives — a Raspberry Pi +next to the devices being commissioned, a container with `hci0` attached, and so on. It executes +the commands the hub sends (scan, connect, read, write, subscribe) against the local adapter and +reports results and notifications back. + +Each discovered peripheral is owned by exactly one proxy client (the one that reports it first, +or a client that takes over if the owner disconnects). The hub can hold connections from +multiple proxy clients at once; commands for a given peripheral route only to its owner. Multiple +BLE-capable hosts can therefore extend one controller's radio range. + +``` +matter.js controller (ProxyBle) + │ + │ WebSocket, path "/ble" + ▼ +BleProxyHandler (hub) + │ │ + │ WebSocket │ WebSocket + ▼ ▼ +proxy client proxy client +(hci0) (hci1) +``` + +## Hub usage + +The hub side needs a WebSocket upgrade path and a `BleProxyHandler` to accept connections on it. +Route WebSocket upgrades for the `/ble` path to `handler.accept(...)`, then register a +`ProxyBle` so the controller uses the proxy instead of local BLE hardware: + +```ts +import { Environment, HttpEndpointFactory } from "@matter/general"; +import "@matter/nodejs-ws"; // registers the WS adapter; transitively bootstraps the Node environment via @matter/nodejs +import { Ble } from "@matter/protocol"; +import { BleProxyHandler, ProxyBle } from "@matter/ws-ble"; + +const handler = new BleProxyHandler(); + +const endpoint = await Environment.default.get(HttpEndpointFactory).create({ address: "http://0.0.0.0:5580" }); +endpoint.ws = async (request, upgrade) => { + if (new URL(request.url).pathname !== "/ble") return; + handler.accept(await upgrade()); +}; + +Environment.default.set(Ble, new ProxyBle(handler, Environment.default)); +``` + +`ProxyBle` only implements central (client) mode; peripheral operations throw. Call +`handler.close()` and `ble.close()` on shutdown to close all proxy client connections. + +## Hardware client (proxy) + +`NobleBleProxyClient` is the reference proxy client: it connects out to the hub and drives a +local Bluetooth adapter via `@stoprocent/noble`. + +```ts +import { NobleBleProxyClient } from "@matter/ws-ble/noble-client"; + +const client = new NobleBleProxyClient({ serverUrl: "ws://hub-host:5580/ble", hciId: 0 }); +await client.connect(); +// ... runs until the hub disconnects or client.close() is called +``` + +The package also installs a `matter-ble-proxy` CLI for running the reference client as a +standalone process — `matter-ble-proxy` on `PATH` after a global install, or +`node_modules/.bin/matter-ble-proxy` when installed as a project dependency: + +``` +matter-ble-proxy --server ws://hub-host:5580/ble [--hci-id 0] +``` + +- `--server ` — BLE proxy WebSocket URL of the hub (required) +- `--hci-id ` — Bluetooth adapter HCI ID, e.g. `0` for `hci0` (Linux only) +- `--help`, `-h` — show usage + +The CLI exits on `SIGINT`/`SIGTERM` and on hub disconnect, so it is meant to run under a +supervisor that restarts it. + +## Protocol summary + +The BLE proxy protocol (version 1, `BLE_PROXY_PROTOCOL_VERSION`) layers a BLE-specific +command/event vocabulary on top of the generic WS-proxy framing shared with other proxies +(`@matter/general`'s `net/ws-proxy`: hello handshake, JSON command/response, JSON events, and a +binary frame format). A connection opens with a `hello`/`hello_response` exchange that pins the +protocol version before either side sends commands. + +| Kind | Names | +| --- | --- | +| Commands (12) | `start_scan`, `stop_scan`, `connect`, `disconnect`, `discover_services`, `discover_characteristics`, `read_characteristic`, `write_characteristic`, `subscribe_characteristic`, `write_and_subscribe`, `unsubscribe_characteristic`, `request_mtu` | +| Events (4) | `device_discovered`, `disconnected`, `scan_stopped`, `characteristic_notification` | + +Binary frames carry high-throughput GATT traffic (writes, notifications, read responses) outside +the JSON envelope. Layout: `[1 byte opcode][2 bytes handle, big-endian][payload]`. + +| Opcode | Direction | Meaning | +| --- | --- | --- | +| `0x01` | hub → client | write payload to the active write characteristic | +| `0x02` | client → hub | notification data from a subscribed characteristic | +| `0x03` | client → hub | response data for a `read_characteristic` command | + +See `src/BleProxyProtocol.ts` for the full command argument/result shapes and error codes. A +Python implementation of the proxy client protocol lives in matterjs-server as its hardware +client. + +## Building + +- `npm run build`: Build all code and create CommonJS and ES6 variants in dist directory. This + will build incrementally and only build the changed files. +- `npm run build-clean`: Clean the dist directory and build all code from scratch + +## Testing + +- `npm run test`: Run the package's test suite diff --git a/packages/ws-ble/package.json b/packages/ws-ble/package.json new file mode 100644 index 0000000000..eaa1471b69 --- /dev/null +++ b/packages/ws-ble/package.json @@ -0,0 +1,89 @@ +{ + "name": "@matter/ws-ble", + "version": "0.0.0-git", + "description": "BLE-over-WebSocket proxy for matter.js", + "keywords": [ + "iot", + "home automation", + "matter", + "smart device", + "ble", + "websocket" + ], + "license": "Apache-2.0", + "author": "matter.js authors", + "bugs": { + "url": "https://github.com/matter-js/matter.js/issues" + }, + "homepage": "https://github.com/matter-js/matter.js", + "repository": { + "type": "git", + "url": "git+https://github.com/matter-js/matter.js.git" + }, + "scripts": { + "clean": "nacho-build clean", + "build": "nacho-build", + "test": "matter-test", + "build-clean": "nacho-build --clean" + }, + "bin": { + "matter-ble-proxy": "dist/cjs/noble-client/cli.js" + }, + "dependencies": { + "@matter/general": "*", + "@matter/protocol": "*" + }, + "optionalDependencies": { + "@matter/nodejs-ws": "*", + "@stoprocent/noble": "^2.7.1" + }, + "devDependencies": { + "@matter/node": "*", + "@matter/nodejs": "*", + "@matter/testing": "*" + }, + "engines": { + "node": ">=20.19.0 <21.0.0 || >=22.13.0" + }, + "type": "module", + "main": "dist/cjs/index.js", + "types": "dist/cjs/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + }, + "./noble-client": { + "import": { + "types": "./dist/esm/noble-client/index.d.ts", + "default": "./dist/esm/noble-client/index.js" + }, + "require": { + "types": "./dist/cjs/noble-client/index.d.ts", + "default": "./dist/cjs/noble-client/index.js" + } + } + }, + "typesVersions": { + "*": { + ".": [ + "/dist/cjs/index.d.ts" + ] + } + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/**/*", + "src/**/*", + "LICENSE", + "README.md" + ] +} diff --git a/packages/ws-ble/src/BleProxyConnection.ts b/packages/ws-ble/src/BleProxyConnection.ts new file mode 100644 index 0000000000..c73b556982 --- /dev/null +++ b/packages/ws-ble/src/BleProxyConnection.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WsProxyConnection, type HttpEndpoint, type Observable } from "@matter/general"; +import { + BLE_PROXY_PROTOCOL_VERSION, + type BinaryFrame, + type BleProxyCommandMap, + type BleProxyCommandName, + type BleProxyEventName, +} from "./BleProxyProtocol.js"; + +/** + * Hub-side BLE proxy connection. + * + * A thin wrapper around {@link WsProxyConnection} that fixes the responder role, the BLE proxy protocol version, and + * the connection id prefix, and narrows the generic command/event vocabulary to the BLE proxy's. All handshake, + * framing, and transport-failure handling is delegated to {@link WsProxyConnection}. + */ +export class BleProxyConnection { + readonly #connection: WsProxyConnection; + + readonly binaryFrameReceived: Observable<[frame: BinaryFrame]>; + readonly eventReceived: Observable<[event: BleProxyEventName, data: Record]>; + readonly handshakeCompleted: Observable<[]>; + readonly closed: Observable<[]>; + + constructor(connection: HttpEndpoint.WsConnection) { + this.#connection = new WsProxyConnection({ + connection, + version: BLE_PROXY_PROTOCOL_VERSION, + role: "responder", + idPrefix: "ble", + }); + + this.binaryFrameReceived = this.#connection.frameReceived; + this.eventReceived = this.#connection.eventReceived as Observable< + [event: BleProxyEventName, data: Record] + >; + this.handshakeCompleted = this.#connection.handshakeCompleted; + this.closed = this.#connection.closed; + + this.#connection.start(); + } + + get id(): string { + return this.#connection.id; + } + + /** See {@link WsProxyConnection.connected}. */ + get connected(): boolean { + return this.#connection.connected; + } + + /** Wait for the handshake to complete. See {@link WsProxyConnection.opened}. */ + opened(): Promise { + return this.#connection.opened(); + } + + /** + * Send a typed command to the BLE proxy client and wait for its response. + */ + async sendCommand( + command: C, + ...rest: BleProxyCommandMap[C]["args"] extends undefined ? [] : [args: BleProxyCommandMap[C]["args"]] + ): Promise { + const [args] = rest; + const result = await this.#connection.sendCommand(command, args as Record | undefined); + return result as BleProxyCommandMap[C]["result"]; + } + + /** Send a raw binary frame to the BLE proxy client. See {@link WsProxyConnection.sendFrame}. */ + sendBinaryFrame(opcode: number, connectionHandle: number, payload: Uint8Array): void { + this.#connection.sendFrame(opcode, connectionHandle, payload); + } + + /** Close the connection, rejecting any pending commands. See {@link WsProxyConnection.close}. */ + close(): Promise { + return this.#connection.close(); + } +} diff --git a/packages/ws-ble/src/BleProxyHandler.ts b/packages/ws-ble/src/BleProxyHandler.ts new file mode 100644 index 0000000000..a305d98878 --- /dev/null +++ b/packages/ws-ble/src/BleProxyHandler.ts @@ -0,0 +1,203 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ImplementationError, Logger, Observable, type HttpEndpoint } from "@matter/general"; +import { BleProxyConnection } from "./BleProxyConnection.js"; +import { BleProxyCommand, BleProxyEvent, type DeviceDiscoveredData, type StartScanArgs } from "./BleProxyProtocol.js"; + +const logger = Logger.get("BleProxyHandler"); + +/** + * Hub for the BLE proxy WebSocket endpoint. + * + * Accepts any number of proxy client connections, broadcasts scan commands to + * all of them, and tracks which client owns each discovered peripheral so that + * per-peripheral traffic routes to a single client. + * + * The endpoint is unauthenticated by design. The embedder that hosts the WebSocket endpoint + * (routes upgrades to {@link accept}) is responsible for securing it — for example by placing + * authentication in front of the upgrade, isolating the network it is reachable on, or fronting + * it with a reverse proxy. + */ +export class BleProxyHandler { + #connections = new Set(); + #closed = false; + #closing?: Promise; + + /** Active scan intent + args, so clients joining mid-scan can be synced. */ + #scanActive = false; + #scanArgs?: StartScanArgs; + /** Connections currently told to scan, for aggregate `scanStopped`. */ + #scanning = new Set(); + + /** address -> owning connection + every connection that has seen it. */ + #owners = new Map }>(); + + // An observer that throws must not abort emission or prevent state updates later in the same handler + readonly #observerFailed = (error: Error) => logger.error("Observer failed:", error); + + /** Emitted whenever a connection completes its handshake. */ + readonly connectionEstablished = new Observable<[]>(this.#observerFailed); + /** Emitted for each `device_discovered`, after ownership is updated. */ + readonly deviceDiscovered = new Observable<[data: DeviceDiscoveredData, connection: BleProxyConnection]>( + this.#observerFailed, + ); + /** Emitted once no connection is scanning anymore. */ + readonly scanStopped = new Observable<[reason: string]>(this.#observerFailed); + + get connected(): boolean { + for (const c of this.#connections) { + if (c.connected) return true; + } + return false; + } + + /** Accept one proxy-client WebSocket. The embedder routes upgrades (e.g. `HttpEndpoint.ws` at `/ble`) here. */ + accept(connection: HttpEndpoint.WsConnection): BleProxyConnection { + if (this.#closed) { + // Never took ownership of this transport, so close it directly rather than leaking the socket + connection.writable.close().catch(err => logger.debug("Error closing output of rejected accept:", err)); + connection.readable.cancel().catch(err => logger.debug("Error cancelling input of rejected accept:", err)); + throw new ImplementationError("BleProxyHandler is closed and cannot accept new connections"); + } + + const bleConnection = new BleProxyConnection(connection); + this.#connections.add(bleConnection); + + bleConnection.handshakeCompleted.on(() => this.#onHandshakeCompleted(bleConnection)); + bleConnection.eventReceived.on((event, data) => this.#onConnectionEvent(bleConnection, event, data)); + bleConnection.closed.on(() => this.#onConnectionClosed(bleConnection)); + + return bleConnection; + } + + /** Close all client connections and stop accepting. Safe to call repeatedly and concurrently. */ + close(): Promise { + return (this.#closing ??= this.#close()); + } + + async #close(): Promise { + this.#closed = true; + + const connections = [...this.#connections]; + this.#connections.clear(); + this.#scanning.clear(); + this.#owners.clear(); + + await Promise.all(connections.map(connection => connection.close())); + } + + /** Returns the connection that owns `address`, if it is still connected. */ + getOwner(address: string): BleProxyConnection | undefined { + const entry = this.#owners.get(address); + if (entry && entry.owner.connected) return entry.owner; + return undefined; + } + + async startScan(args: StartScanArgs): Promise { + this.#scanActive = true; + this.#scanArgs = args; + const sends = new Array>(); + for (const connection of this.#connections) { + if (connection.connected) { + this.#scanning.add(connection); + sends.push( + connection.sendCommand(BleProxyCommand.StartScan, args).catch(err => { + logger.warn(`[${connection.id}] Failed to start scan:`, err); + this.#markNotScanning(connection, "start scan failed"); + }), + ); + } + } + await Promise.all(sends); + } + + async stopScan(): Promise { + this.#scanActive = false; + const sends = new Array>(); + for (const connection of this.#connections) { + if (connection.connected) { + sends.push( + connection + .sendCommand(BleProxyCommand.StopScan) + .catch(err => logger.warn(`[${connection.id}] Failed to stop scan:`, err)), + ); + } + } + this.#scanning.clear(); + await Promise.all(sends); + } + + #onHandshakeCompleted(connection: BleProxyConnection): void { + if (this.#scanActive && this.#scanArgs) { + this.#scanning.add(connection); + connection.sendCommand(BleProxyCommand.StartScan, this.#scanArgs).catch(err => { + logger.warn(`[${connection.id}] Failed to sync scan to joining client:`, err); + this.#markNotScanning(connection, "start scan failed"); + }); + } + this.connectionEstablished.emit(); + } + + #onConnectionEvent(connection: BleProxyConnection, event: string, data: Record): void { + if (event === BleProxyEvent.DeviceDiscovered) { + this.#onDeviceDiscovered(connection, data as unknown as DeviceDiscoveredData); + } else if (event === BleProxyEvent.ScanStopped) { + this.#markNotScanning(connection, (data as { reason?: string }).reason ?? "unknown"); + } + } + + /** Drop a connection from the scanning set; emit aggregate scanStopped once none remain. */ + #markNotScanning(connection: BleProxyConnection, reason: string): void { + if (!this.#scanning.delete(connection)) { + return; + } + if (this.#scanning.size === 0) { + this.scanStopped.emit(reason); + } + } + + #onDeviceDiscovered(connection: BleProxyConnection, data: DeviceDiscoveredData): void { + let entry = this.#owners.get(data.address); + if (!entry) { + entry = { owner: connection, seers: new Set([connection]) }; + this.#owners.set(data.address, entry); + } else { + entry.seers.add(connection); + if (!entry.owner.connected) { + entry.owner = connection; + } + } + logger.debug( + `[${connection.id}] device_discovered ${data.address} rssi=${data.rssi ?? "n/a"} seers=${entry.seers.size} isOwner=${entry.owner === connection}`, + ); + this.deviceDiscovered.emit(data, connection); + } + + #onConnectionClosed(connection: BleProxyConnection): void { + this.#connections.delete(connection); + this.#markNotScanning(connection, "client disconnected"); + + for (const [address, entry] of this.#owners) { + entry.seers.delete(connection); + if (entry.owner === connection) { + let next: BleProxyConnection | undefined; + for (const seer of entry.seers) { + if (seer.connected) { + next = seer; + break; + } + } + if (next) { + entry.owner = next; + logger.info(`Reassigned ownership of ${address} to [${next.id}]`); + } else { + this.#owners.delete(address); + } + } + } + } +} diff --git a/packages/ws-ble/src/BleProxyProtocol.ts b/packages/ws-ble/src/BleProxyProtocol.ts new file mode 100644 index 0000000000..00def41481 --- /dev/null +++ b/packages/ws-ble/src/BleProxyProtocol.ts @@ -0,0 +1,256 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * BLE Proxy Protocol constants, types, and codec. + * + * The JSON command/event envelope, hello handshake, and binary frame codec are the shared + * ws-proxy wire format from `@matter/general`; this module adds the BLE-specific vocabulary + * (command/event names, args/result/data shapes, error codes) on top of it. + */ + +// ─── Protocol Version ──────────────────────────────────────────────────────── + +export const BLE_PROXY_PROTOCOL_VERSION = 1; + +// ─── Command Names ─────────────────────────────────────────────────────────── + +export const BleProxyCommand = { + StartScan: "start_scan", + StopScan: "stop_scan", + Connect: "connect", + Disconnect: "disconnect", + DiscoverServices: "discover_services", + DiscoverCharacteristics: "discover_characteristics", + ReadCharacteristic: "read_characteristic", + WriteCharacteristic: "write_characteristic", + SubscribeCharacteristic: "subscribe_characteristic", + WriteAndSubscribe: "write_and_subscribe", + UnsubscribeCharacteristic: "unsubscribe_characteristic", + RequestMtu: "request_mtu", +} as const; + +export type BleProxyCommandName = (typeof BleProxyCommand)[keyof typeof BleProxyCommand]; + +// ─── Event Names ───────────────────────────────────────────────────────────── + +export const BleProxyEvent = { + DeviceDiscovered: "device_discovered", + Disconnected: "disconnected", + ScanStopped: "scan_stopped", + CharacteristicNotification: "characteristic_notification", +} as const; + +export type BleProxyEventName = (typeof BleProxyEvent)[keyof typeof BleProxyEvent]; + +// ─── Handshake + Envelope + Binary Frame Codec (shared ws-proxy layer) ─────── + +import type { + WsProxyCommandMessage, + WsProxyEventMessage, + WsProxyHelloMessage, + WsProxyHelloResponseMessage, +} from "@matter/general"; + +export { + decodeWsProxyFrame as decodeBinaryFrame, + encodeWsProxyFrame as encodeBinaryFrame, + type WsProxyFrame as BinaryFrame, +} from "@matter/general"; +export type { + WsProxyHelloMessage as HelloMessage, + WsProxyHelloResponseMessage as HelloResponseMessage, + WsProxyResponseMessage as ResponseMessage, + WsProxySuccessResponse as SuccessResponseMessage, + WsProxyErrorResponse as ErrorResponseMessage, +} from "@matter/general"; + +export type HandshakeMessage = WsProxyHelloMessage | WsProxyHelloResponseMessage; + +/** {@link WsProxyCommandMessage} narrowed to the BLE proxy's command vocabulary. */ +export type CommandMessage = Omit & { command: BleProxyCommandName }; + +/** {@link WsProxyEventMessage} narrowed to the BLE proxy's event vocabulary. */ +export type EventMessage = Omit & { event: BleProxyEventName }; + +// ─── Command Args ──────────────────────────────────────────────────────────── + +export interface StartScanArgs { + service_uuids?: string[]; + allow_duplicates?: boolean; +} + +export interface ConnectArgs { + address: string; + timeout?: number; +} + +export interface DisconnectArgs { + connection_handle: number; +} + +export interface DiscoverServicesArgs { + connection_handle: number; +} + +export interface DiscoverCharacteristicsArgs { + connection_handle: number; + service_uuid: string; +} + +export interface ReadCharacteristicArgs { + connection_handle: number; + characteristic_uuid: string; +} + +export interface WriteCharacteristicArgs { + connection_handle: number; + characteristic_uuid: string; + value: string; // base64 + response?: boolean; +} + +export interface SubscribeCharacteristicArgs { + connection_handle: number; + characteristic_uuid: string; +} + +/** + * Atomic write-then-subscribe. The client performs the write to `write_uuid`, awaits the + * GATT Write Response, then enables CCCD on `subscribe_uuid` without any intervening + * WebSocket round-trip. Used for the Matter BTP handshake on C1/C2 where a peripheral may + * push the handshake response indication before the client has had time to enable CCCD if + * the two ops are split across WS commands. + */ +export interface WriteAndSubscribeArgs { + connection_handle: number; + write_uuid: string; + write_value: string; // base64 + write_response?: boolean; + subscribe_uuid: string; +} + +export interface UnsubscribeCharacteristicArgs { + connection_handle: number; + characteristic_uuid: string; +} + +export interface RequestMtuArgs { + connection_handle: number; + mtu: number; +} + +// ─── Command Results ───────────────────────────────────────────────────────── + +export interface ConnectResult { + connection_handle: number; + mtu: number; +} + +export interface DiscoverServicesResult { + services: Array<{ uuid: string }>; +} + +export interface DiscoverCharacteristicsResult { + characteristics: Array<{ + uuid: string; + properties: string[]; + }>; +} + +export interface ReadCharacteristicResult { + value: string; // base64 +} + +export interface RequestMtuResult { + mtu: number; +} + +// ─── Event Data ────────────────────────────────────────────────────────────── + +export interface DeviceDiscoveredData { + address: string; + name?: string; + rssi?: number; + connectable: boolean; + service_data?: Record; // uuid -> base64 + manufacturer_data?: Record; // id -> base64 + service_uuids?: string[]; +} + +export interface DisconnectedData { + connection_handle: number; + reason?: string; +} + +export interface ScanStoppedData { + reason: string; +} + +export interface CharacteristicNotificationData { + connection_handle: number; + characteristic_uuid: string; + value: string; // base64 +} + +// ─── Command Type Map ──────────────────────────────────────────────────────── + +/** Maps each command name to its args and result types for type-safe sendCommand. */ +export interface BleProxyCommandMap { + [BleProxyCommand.StartScan]: { args: StartScanArgs; result: void }; + [BleProxyCommand.StopScan]: { args: undefined; result: void }; + [BleProxyCommand.Connect]: { args: ConnectArgs; result: ConnectResult }; + [BleProxyCommand.Disconnect]: { args: DisconnectArgs; result: void }; + [BleProxyCommand.DiscoverServices]: { args: DiscoverServicesArgs; result: DiscoverServicesResult }; + [BleProxyCommand.DiscoverCharacteristics]: { + args: DiscoverCharacteristicsArgs; + result: DiscoverCharacteristicsResult; + }; + [BleProxyCommand.ReadCharacteristic]: { args: ReadCharacteristicArgs; result: ReadCharacteristicResult }; + [BleProxyCommand.WriteCharacteristic]: { args: WriteCharacteristicArgs; result: void }; + [BleProxyCommand.SubscribeCharacteristic]: { args: SubscribeCharacteristicArgs; result: void }; + [BleProxyCommand.WriteAndSubscribe]: { args: WriteAndSubscribeArgs; result: void }; + [BleProxyCommand.UnsubscribeCharacteristic]: { args: UnsubscribeCharacteristicArgs; result: void }; + [BleProxyCommand.RequestMtu]: { args: RequestMtuArgs; result: RequestMtuResult }; +} + +// ─── Error Codes ───────────────────────────────────────────────────────────── + +export const BleProxyErrorCode = { + BluetoothUnavailable: "bluetooth_unavailable", + AlreadyScanning: "already_scanning", + NotScanning: "not_scanning", + DeviceNotFound: "device_not_found", + ConnectionFailed: "connection_failed", + AlreadyConnected: "already_connected", + NotConnected: "not_connected", + Timeout: "timeout", + ServiceNotFound: "service_not_found", + CharacteristicNotFound: "characteristic_not_found", + ReadFailed: "read_failed", + WriteFailed: "write_failed", + SubscribeFailed: "subscribe_failed", + NotSubscribed: "not_subscribed", + NotifyNotSupported: "notify_not_supported", + MtuRequestFailed: "mtu_request_failed", + DiscoveryFailed: "discovery_failed", + InternalError: "internal_error", +} as const; + +export type BleProxyErrorCodeValue = (typeof BleProxyErrorCode)[keyof typeof BleProxyErrorCode]; + +// ─── Binary Frame Opcodes ──────────────────────────────────────────────────── + +export const BinaryFrameOpcode = { + /** Server -> Client: write payload to the active write characteristic */ + WriteData: 0x01, + /** Client -> Server: notification data from a subscribed characteristic */ + Notification: 0x02, + /** Client -> Server: response data for a read_characteristic command */ + ReadResponse: 0x03, +} as const; + +export type BinaryFrameOpcodeValue = (typeof BinaryFrameOpcode)[keyof typeof BinaryFrameOpcode]; diff --git a/packages/ws-ble/src/ProxyBle.ts b/packages/ws-ble/src/ProxyBle.ts new file mode 100644 index 0000000000..5beabac3e8 --- /dev/null +++ b/packages/ws-ble/src/ProxyBle.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ImplementationError, type Environment, type Transport } from "@matter/general"; +import { Ble, type BlePeripheralInterface, type Scanner } from "@matter/protocol"; +import type { BleProxyHandler } from "./BleProxyHandler.js"; +import { ProxyBleCentralInterface } from "./ProxyBleChannel.js"; +import { ProxyBleClient } from "./ProxyBleClient.js"; +import { ProxyBleScanner } from "./ProxyBleScanner.js"; + +/** + * BLE implementation that proxies all operations over a WebSocket connection, for environments where BLE hardware is + * reachable only through a remote proxy client. + * + * Only central (client) mode is supported; {@link peripheralInterface} throws. + */ +export class ProxyBle extends Ble { + readonly #handler: BleProxyHandler; + #proxyBleClient?: ProxyBleClient; + #bleScanner?: ProxyBleScanner; + #bleCentralInterface?: ProxyBleCentralInterface; + #closed = false; + + constructor(handler: BleProxyHandler, environment?: Environment) { + super(); + this.#handler = handler; + // Runtime registration makes shutdown drive scanner.close(), which clears pending discovery waiters + environment?.runtime.add(this); + } + + get peripheralInterface(): BlePeripheralInterface { + throw new ImplementationError("BLE Proxy only supports central mode, not peripheral"); + } + + get centralInterface(): Transport { + if (!this.#bleCentralInterface) { + this.#bleCentralInterface = new ProxyBleCentralInterface(this.scanner as ProxyBleScanner, this.#handler); + } + return this.#bleCentralInterface; + } + + get scanner(): Scanner { + if (!this.#bleScanner) { + if (!this.#proxyBleClient) { + this.#proxyBleClient = new ProxyBleClient(this.#handler); + } + this.#bleScanner = new ProxyBleScanner(this.#proxyBleClient); + } + return this.#bleScanner; + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + await this.#bleCentralInterface?.close(); + // scanner.close() also closes the underlying ProxyBleClient + await this.#bleScanner?.close(); + } +} diff --git a/packages/ws-ble/src/ProxyBleChannel.ts b/packages/ws-ble/src/ProxyBleChannel.ts new file mode 100644 index 0000000000..f23e6d4016 --- /dev/null +++ b/packages/ws-ble/src/ProxyBleChannel.ts @@ -0,0 +1,451 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Bytes, + ChannelType, + createPromise, + InternalError, + Logger, + NetworkError, + Seconds, + ServerAddress, + Time, + withTimeout, + type Channel, + type Transport, +} from "@matter/general"; +import { BleChannel, BleError, BtpCodec, BtpFlowError, BtpSessionHandler, MatterBle } from "@matter/protocol"; +import type { BleProxyHandler } from "./BleProxyHandler.js"; +import { BinaryFrameOpcode, BleProxyCommand, BleProxyEvent, type BinaryFrame } from "./BleProxyProtocol.js"; +import type { ProxyBleScanner } from "./ProxyBleScanner.js"; + +const logger = Logger.get("ProxyBleChannel"); + +/** + * BTP handshake response identification bytes. + * + * @see {@link MatterSpecification.v16.Core} § 4.19.3.2 + */ +const BTP_HANDSHAKE_RESPONSE_OPCODE_1 = 0x65; +const BTP_HANDSHAKE_RESPONSE_OPCODE_2 = 0x6c; +const BTP_HANDSHAKE_RESPONSE_LENGTH = 6; + +/** Channel teardown must not stall on an unresponsive proxy client, so the courtesy Disconnect is bounded. */ +const DISCONNECT_TIMEOUT = Seconds(5); + +/** + * Normalize any UUID form sent by a proxy client to the canonical dashed-uppercase form used by Matter's + * {@link MatterBle} constants. Different BLE proxy clients deliver different formats: + * + * - noble: 32 lowercase hex chars, no dashes ("18ee2ef5263d4559959f4f9c429f9d11") + * - generic: dashed form, either case ("18EE2EF5-263D-4559-959F-4F9C429F9D11") + * + * Both produce the same canonical string so command handlers stay format-agnostic. The 16-bit short form ("fff6") + * is only uppercased, which is what {@link MatterBle.isServiceUuid} expects for service UUIDs. + */ +export function toCanonicalUuid(uuid: string): string { + const upper = uuid.toUpperCase(); + if (upper.length === 32) { + return [ + upper.substring(0, 8), + upper.substring(8, 12), + upper.substring(12, 16), + upper.substring(16, 20), + upper.substring(20, 32), + ].join("-"); + } + return upper; +} + +/** + * {@link Transport} that opens BLE channels through the proxy WebSocket. + */ +export class ProxyBleCentralInterface implements Transport { + readonly #bleScanner: ProxyBleScanner; + readonly #handler: BleProxyHandler; + #onMatterMessageListener: ((socket: Channel, data: Bytes) => void) | undefined; + #closed = false; + + constructor(bleScanner: ProxyBleScanner, handler: BleProxyHandler) { + this.#bleScanner = bleScanner; + this.#handler = handler; + } + + async openChannel(address: ServerAddress): Promise> { + if (this.#closed) { + throw new NetworkError("Network interface is closed"); + } + if (!ServerAddress.isBle(address)) { + throw new InternalError(`Unsupported address type for BLE channel.`); + } + if (this.#onMatterMessageListener === undefined) { + throw new InternalError("Network Interface was not added to the system yet."); + } + + const { peripheralAddress } = address; + + const connection = this.#handler.getOwner(peripheralAddress); + if (!connection) { + throw new BleError(`No connected BLE proxy client owns peripheral ${peripheralAddress}`); + } + + const discovered = this.#bleScanner.getDiscoveredDevice(peripheralAddress); + const { hasAdditionalAdvertisementData } = discovered; + const rssi = discovered.peripheral.rssi; + + logger.debug(`Connecting to peripheral ${peripheralAddress} (rssi=${rssi ?? "n/a"}) via proxy`); + + const { connection_handle, mtu: peripheralMtu } = await connection.sendCommand(BleProxyCommand.Connect, { + address: peripheralAddress, + }); + + const mtu = MatterBle.btpSegmentSizeFromAttMtu(peripheralMtu ?? 0); + logger.info( + `Connected to ${peripheralAddress}, handle=${connection_handle}, BTP segment size=${mtu} bytes (peripheral ATT_MTU up to ${peripheralMtu ?? "n/a"}), rssi=${rssi ?? "n/a"}`, + ); + + // The owner connection's observables outlive a failed open; a leaked observer would corrupt the next channel on + // the same connection. Detach on every exit path; assigned once registered. + let detachObservers: (() => void) | undefined; + + try { + // The client chooses the handle and every frame of this channel is addressed with it + if (!Number.isInteger(connection_handle) || connection_handle < 0 || connection_handle > 0xffff) { + throw new BleError( + `BLE proxy client returned invalid connection handle ${connection_handle} for ${peripheralAddress}`, + ); + } + + const { services } = await connection.sendCommand(BleProxyCommand.DiscoverServices, { + connection_handle, + }); + + const matterService = services.find(s => MatterBle.isServiceUuid(toCanonicalUuid(s.uuid))); + if (!matterService) { + throw new BleError(`Peripheral ${peripheralAddress} does not have Matter BLE service`); + } + + const { characteristics } = await connection.sendCommand(BleProxyCommand.DiscoverCharacteristics, { + connection_handle, + service_uuid: matterService.uuid, + }); + + let c1Uuid: string | undefined; + let c2Uuid: string | undefined; + let c3Uuid: string | undefined; + + for (const char of characteristics) { + const canonical = toCanonicalUuid(char.uuid); + if (canonical === MatterBle.C1_CHARACTERISTIC_UUID) { + c1Uuid = char.uuid; + } else if (canonical === MatterBle.C2_CHARACTERISTIC_UUID) { + c2Uuid = char.uuid; + } else if (canonical === MatterBle.C3_CHARACTERISTIC_UUID) { + c3Uuid = char.uuid; + } + } + + if (!c1Uuid || !c2Uuid) { + throw new BleError(`Peripheral ${peripheralAddress} missing required Matter characteristics (C1/C2)`); + } + + if (c3Uuid && hasAdditionalAdvertisementData) { + logger.debug(`Reading additional commissioning data from C3`); + await connection.sendCommand(BleProxyCommand.ReadCharacteristic, { + connection_handle, + characteristic_uuid: c3Uuid, + }); + } + + // Register the handshake observer BEFORE sending: the C2 indication is a separate binary frame that can + // beat the WriteAndSubscribe response, and binaryFrameReceived drops frames emitted with no listener + // attached. + const { + promise: handshakePromise, + resolver: handshakeResolver, + rejecter: handshakeRejecter, + } = createPromise(); + + const btpHandshakeTimeout = Time.getTimer( + "BLE proxy handshake timeout", + MatterBle.BTP_CONN_RSP_TIMEOUT, + () => { + handshakeRejecter(new BleError(`BTP handshake response not received from ${peripheralAddress}`)); + }, + ).start(); + + const handshakeObserver = (frame: BinaryFrame) => { + if (frame.handle === connection_handle && frame.opcode === BinaryFrameOpcode.Notification) { + const data = new Uint8Array(frame.payload); + if ( + data[0] === BTP_HANDSHAKE_RESPONSE_OPCODE_1 && + data[1] === BTP_HANDSHAKE_RESPONSE_OPCODE_2 && + data.length === BTP_HANDSHAKE_RESPONSE_LENGTH + ) { + btpHandshakeTimeout.stop(); + handshakeResolver(data); + } + } + }; + connection.binaryFrameReceived.on(handshakeObserver); + + let handshakeResponse: Uint8Array; + try { + // Write C1 and subscribe C2 atomically so the peripheral can't fire its indication before + // notifications are enabled (no round-trip between Write Response and CCCD enable). + const btpHandshakeRequest = BtpCodec.encodeBtpHandshakeRequest({ + versions: MatterBle.BTP_SUPPORTED_VERSIONS, + attMtu: mtu, + clientWindowSize: MatterBle.BTP_MAXIMUM_WINDOW_SIZE, + }); + logger.debug(`Sending BTP handshake request on C1 and subscribing C2 atomically`); + + // Await both together, not sequentially: the handshake timer can reject handshakePromise while the + // write is still pending, and Promise.all keeps a handler on it from the start so that rejection + // surfaces here instead of escaping as an unhandled rejection. + const writeAndSubscribe = connection.sendCommand(BleProxyCommand.WriteAndSubscribe, { + connection_handle, + write_uuid: c1Uuid, + write_value: Bytes.toBase64(btpHandshakeRequest), + write_response: true, + subscribe_uuid: c2Uuid, + }); + [, handshakeResponse] = await Promise.all([writeAndSubscribe, handshakePromise]); + } finally { + connection.binaryFrameReceived.off(handshakeObserver); + btpHandshakeTimeout.stop(); + } + + // Register the live observer BEFORE creating the session (same non-buffering frame race as the handshake + // observer above); frames seen before the session exists are buffered, then flushed once it exists. + const onMatterMessageListener = this.#onMatterMessageListener; + const channelRef: { channel?: ProxyBleChannel } = {}; + const sessionRef: { session?: BtpSessionHandler } = {}; + const earlyFrames = new Array(); + + const forwardToBtp = (payload: Uint8Array) => { + sessionRef.session + ?.handleIncomingBleData(payload) + .catch(error => + logger.warn(`Peripheral ${peripheralAddress}: Error handling incoming BLE data`, error), + ); + }; + + const binaryObserver = (frame: BinaryFrame) => { + if (frame.handle === connection_handle && frame.opcode === BinaryFrameOpcode.Notification) { + const payload = new Uint8Array(frame.payload); + if (sessionRef.session) { + forwardToBtp(payload); + } else { + earlyFrames.push(payload); + } + } + }; + connection.binaryFrameReceived.on(binaryObserver); + detachObservers = () => connection.binaryFrameReceived.off(binaryObserver); + + const btpSession = await BtpSessionHandler.createAsCentral( + handshakeResponse, + async (data: Bytes) => { + connection.sendBinaryFrame(BinaryFrameOpcode.WriteData, connection_handle, Bytes.of(data)); + }, + async () => { + if (!channelRef.channel?.connected) return; + logger.debug(`Disconnecting from ${peripheralAddress} via proxy`); + try { + await withTimeout( + DISCONNECT_TIMEOUT, + connection.sendCommand(BleProxyCommand.Disconnect, { connection_handle }), + ); + } catch (error) { + logger.debug( + `Peripheral ${peripheralAddress}: Error sending Disconnect to proxy client`, + error, + ); + } + }, + async (data: Bytes) => { + if (channelRef.channel) { + channelRef.channel.pushMessage(data); + onMatterMessageListener(channelRef.channel, data); + } + }, + ); + sessionRef.session = btpSession; + + for (const payload of earlyFrames) { + forwardToBtp(payload); + } + earlyFrames.length = 0; + + const eventObserver = (event: string, data: Record) => { + if ( + event === BleProxyEvent.Disconnected && + typeof data.connection_handle === "number" && + data.connection_handle === connection_handle + ) { + logger.info(`Peripheral ${peripheralAddress} disconnected unexpectedly`); + channelRef.channel?.markDisconnected(); + channelRef.channel + ?.close() + .catch(error => logger.debug(`Peripheral ${peripheralAddress}: Error closing channel`, error)); + } + }; + connection.eventReceived.on(eventObserver); + + // The Disconnected event covers one peripheral; this covers the whole owning client vanishing + const ownerClosedObserver = () => { + logger.info(`Owning proxy client for ${peripheralAddress} disconnected`); + channelRef.channel?.markDisconnected(); + channelRef.channel + ?.close() + .catch(error => logger.debug(`Peripheral ${peripheralAddress}: Error closing channel`, error)); + }; + connection.closed.on(ownerClosedObserver); + + detachObservers = () => { + connection.binaryFrameReceived.off(binaryObserver); + connection.eventReceived.off(eventObserver); + connection.closed.off(ownerClosedObserver); + }; + + const proxyChannel = new ProxyBleChannel(peripheralAddress, btpSession, detachObservers); + channelRef.channel = proxyChannel; + return proxyChannel; + } catch (error) { + detachObservers?.(); + try { + await connection.sendCommand(BleProxyCommand.Disconnect, { connection_handle }); + } catch (cleanupError) { + logger.debug(`Peripheral ${peripheralAddress}: Error during connect-failure cleanup`, cleanupError); + } + throw error; + } + } + + onData(listener: (socket: Channel, data: Bytes) => void): Transport.Listener { + this.#onMatterMessageListener = listener; + return { + close: async () => await this.close(), + }; + } + + async close() { + this.#closed = true; + } + + supports(type: ChannelType, _address?: string) { + return type === ChannelType.BLE; + } +} + +/** + * BLE channel that communicates through the proxy WebSocket. + */ +export class ProxyBleChannel extends BleChannel { + #connected = true; + readonly #peripheralAddress: string; + readonly #btpSession: BtpSessionHandler; + readonly #cleanupObservers: () => void; + readonly #onBtpSessionClosed: () => void; + readonly #closeListeners = new Set<() => void>(); + #iteratorQueue = new Array(); + #iteratorWaiter?: (value: IteratorResult) => void; + #iteratorDone = false; + + constructor(peripheralAddress: string, btpSession: BtpSessionHandler, cleanupObservers: () => void) { + super(); + this.#peripheralAddress = peripheralAddress; + this.#btpSession = btpSession; + this.#cleanupObservers = cleanupObservers; + this.#onBtpSessionClosed = () => this.emitClosed(); + btpSession.closed.on(this.#onBtpSessionClosed); + } + + get connected() { + return this.#connected; + } + + markDisconnected() { + this.#connected = false; + } + + pushMessage(data: Bytes): void { + if (this.#iteratorWaiter) { + const resolve = this.#iteratorWaiter; + this.#iteratorWaiter = undefined; + resolve({ value: data, done: false }); + } else if (!this.#iteratorDone) { + this.#iteratorQueue.push(data); + } + } + + onClose(listener: () => void): Transport.Listener { + this.#closeListeners.add(listener); + return { + close: async () => { + this.#closeListeners.delete(listener); + }, + }; + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + if (this.#iteratorQueue.length > 0) { + return Promise.resolve({ value: this.#iteratorQueue.shift()!, done: false }); + } + if (this.#iteratorDone || !this.#connected) { + return Promise.resolve({ value: undefined as unknown as Bytes, done: true }); + } + return new Promise>(resolve => { + this.#iteratorWaiter = resolve; + }); + }, + }; + } + + #terminateIterator(): void { + if (!this.#iteratorDone) { + this.#iteratorDone = true; + this.#iteratorWaiter?.({ value: undefined, done: true }); + this.#iteratorWaiter = undefined; + } + } + + async send(data: Bytes) { + if (!this.#connected) { + logger.debug(`Cannot send data - not connected to ${this.#peripheralAddress}`); + return; + } + if (this.#btpSession === undefined) { + throw new BtpFlowError(`Cannot send data, no BTP session initialized`); + } + await this.#btpSession.sendMatterMessage(data); + } + + get name() { + return `ble-proxy://${this.#peripheralAddress}`; + } + + async close() { + this.#cleanupObservers(); + this.#terminateIterator(); + for (const listener of this.#closeListeners) { + listener(); + } + this.#btpSession.closed.off(this.#onBtpSessionClosed); + try { + // The session's disconnect callback tests {@link connected} to decide whether the peripheral still needs + // a Disconnect, so the flag must survive until the session has closed + await this.#btpSession.close(); + } finally { + this.#connected = false; + } + this.emitClosed(); + } +} diff --git a/packages/ws-ble/src/ProxyBleClient.ts b/packages/ws-ble/src/ProxyBleClient.ts new file mode 100644 index 0000000000..f7db22bc78 --- /dev/null +++ b/packages/ws-ble/src/ProxyBleClient.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Bytes, createPromise, Duration, Logger, Seconds, withTimeout } from "@matter/general"; +import { BleError, MatterBle } from "@matter/protocol"; +import type { BleProxyConnection } from "./BleProxyConnection.js"; +import type { BleProxyHandler } from "./BleProxyHandler.js"; +import type { DeviceDiscoveredData } from "./BleProxyProtocol.js"; +import { toCanonicalUuid } from "./ProxyBleChannel.js"; + +const logger = Logger.get("ProxyBleClient"); + +/** + * Bounded wait for a proxy client to attach before starting a scan. Long enough that "start the hub, then connect the + * proxy" works, short enough that a misconfigured deployment surfaces a clear error instead of an opaque + * scan-never-returns hang on the caller side. + */ +const SCAN_CONNECT_WAIT = Seconds(30); + +/** + * A BLE peripheral discovered through the proxy, holding the data reported by the proxy client. + */ +export interface ProxyPeripheral { + address: string; + name?: string; + rssi?: number; + connectable: boolean; + serviceData: Map; + mtu?: number; +} + +/** + * BLE scanner client that scans through the BLE proxy protocol: sends scan commands to the proxy clients and consumes + * the resulting `device_discovered` events. + */ +export class ProxyBleClient { + readonly #handler: BleProxyHandler; + readonly #discoveredPeripherals = new Map(); + #deviceDiscoveredCallback: ((peripheral: ProxyPeripheral, matterServiceData: Uint8Array) => void) | undefined; + #isScanning = false; + + readonly #deviceDiscoveredObserver = (data: DeviceDiscoveredData, _connection: BleProxyConnection) => { + this.#handleDeviceDiscovered(data); + }; + + readonly #scanStoppedObserver = (reason: string) => { + logger.info(`Scan stopped by proxy client: ${reason}`); + this.#isScanning = false; + }; + + constructor(handler: BleProxyHandler) { + this.#handler = handler; + this.#handler.deviceDiscovered.on(this.#deviceDiscoveredObserver); + this.#handler.scanStopped.on(this.#scanStoppedObserver); + } + + setDiscoveryCallback(callback: (peripheral: ProxyPeripheral, matterServiceData: Uint8Array) => void): void { + this.#deviceDiscoveredCallback = callback; + for (const { peripheral, matterServiceData } of this.#discoveredPeripherals.values()) { + this.#deviceDiscoveredCallback(peripheral, matterServiceData); + } + } + + async startScanning(): Promise { + if (this.#isScanning) { + return; + } + + if (!this.#handler.connected) { + logger.info(`BLE proxy not connected, waiting up to ${Duration.format(SCAN_CONNECT_WAIT)} for client`); + const { promise, resolver } = createPromise(); + const onConnect = () => resolver(); + this.#handler.connectionEstablished.on(onConnect); + try { + await withTimeout(SCAN_CONNECT_WAIT, promise); + } catch (cause) { + throw new BleError( + `BLE proxy client did not connect within ${Duration.format(SCAN_CONNECT_WAIT)} — cannot start scan`, + { cause }, + ); + } finally { + this.#handler.connectionEstablished.off(onConnect); + } + } + + logger.debug("Start BLE scanning via proxy ..."); + // Claim the scan before awaiting: a second caller entering this window would broadcast a duplicate start_scan, + // and the resulting already_scanning error clears the hub's scanning set while this client still scans + this.#isScanning = true; + try { + // Matter discovery only needs one event per state change; opt out of the spec's default true so a 10 Hz + // peripheral advertise doesn't flood the WebSocket + await this.#handler.startScan({ service_uuids: ["fff6"], allow_duplicates: false }); + } catch (error) { + this.#isScanning = false; + throw error; + } + } + + async stopScanning(): Promise { + // Clear hub scan intent unconditionally: a transient all-clients-disconnect can reset #isScanning while the + // hub still intends to scan, and skipping stopScan here would let a reconnecting client auto-resume a scan the + // caller already ended + this.#isScanning = false; + logger.debug("Stop BLE scanning via proxy ..."); + await this.#handler.stopScan(); + } + + close(): void { + this.#isScanning = false; + this.#handler.deviceDiscovered.off(this.#deviceDiscoveredObserver); + this.#handler.scanStopped.off(this.#scanStoppedObserver); + } + + #handleDeviceDiscovered(data: DeviceDiscoveredData): void { + const { address, name, rssi, connectable, service_data } = data; + + const serviceData = new Map(); + let matterServiceData: Uint8Array | undefined; + if (service_data) { + for (const [uuid, base64Value] of Object.entries(service_data)) { + let bytes; + try { + bytes = Bytes.of(Bytes.fromBase64(base64Value)); + } catch (error) { + logger.debug(`Peripheral ${address} sent undecodable service data for ${uuid}, ignoring it`, error); + continue; + } + serviceData.set(uuid, bytes); + if (MatterBle.isServiceUuid(toCanonicalUuid(uuid))) { + matterServiceData = bytes; + } + } + } + + const peripheral: ProxyPeripheral = { + address, + name, + rssi, + connectable, + serviceData, + }; + + if (!connectable) { + logger.debug(`Peripheral ${address} is not connectable, ignoring`); + return; + } + + if (matterServiceData === undefined || matterServiceData.length !== 8) { + logger.debug(`Peripheral ${address} does not advertise valid Matter service data, ignoring`); + return; + } + + logger.info( + `Discovered commissionable device ${address} (${name ?? "unnamed"}) rssi=${rssi ?? "n/a"} via proxy`, + ); + this.#discoveredPeripherals.set(address, { peripheral, matterServiceData }); + this.#deviceDiscoveredCallback?.(peripheral, matterServiceData); + } +} diff --git a/packages/ws-ble/src/ProxyBleScanner.ts b/packages/ws-ble/src/ProxyBleScanner.ts new file mode 100644 index 0000000000..e4efed5250 --- /dev/null +++ b/packages/ws-ble/src/ProxyBleScanner.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BleScanner as BaseBleScanner, type DiscoveredBleDevice } from "@matter/protocol"; +import type { ProxyBleClient, ProxyPeripheral } from "./ProxyBleClient.js"; + +export type { DiscoveredBleDevice } from "@matter/protocol"; + +export type DiscoveredProxyBleDevice = Omit & { peripheral: ProxyPeripheral }; + +/** + * BLE scanner that discovers Matter devices through the BLE proxy. + * + * Extends matter.js's base {@link BaseBleScanner}, which already handles the + * `findCommissionableDevicesContinuously` waiter loop, advertisement parsing, and cancellation semantics. This + * subclass only narrows the {@link getDiscoveredDevice} return type to expose the proxy-side {@link ProxyPeripheral} + * and routes `closeClient` through {@link ProxyBleClient.close}. + */ +export class ProxyBleScanner extends BaseBleScanner { + readonly #proxyClient: ProxyBleClient; + + constructor(proxyClient: ProxyBleClient) { + super(proxyClient); + this.#proxyClient = proxyClient; + } + + override getDiscoveredDevice(address: string): DiscoveredProxyBleDevice { + return super.getDiscoveredDevice(address) as DiscoveredProxyBleDevice; + } + + protected override closeClient(): void { + this.#proxyClient.close(); + } +} diff --git a/packages/ws-ble/src/index.ts b/packages/ws-ble/src/index.ts new file mode 100644 index 0000000000..062942970f --- /dev/null +++ b/packages/ws-ble/src/index.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./BleProxyConnection.js"; +export * from "./BleProxyHandler.js"; +export * from "./BleProxyProtocol.js"; +export * from "./ProxyBle.js"; +export * from "./ProxyBleChannel.js"; +export * from "./ProxyBleClient.js"; +export * from "./ProxyBleScanner.js"; diff --git a/packages/ws-ble/src/noble-client/NobleBleProxyClient.ts b/packages/ws-ble/src/noble-client/NobleBleProxyClient.ts new file mode 100644 index 0000000000..b295065bf1 --- /dev/null +++ b/packages/ws-ble/src/noble-client/NobleBleProxyClient.ts @@ -0,0 +1,898 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Noble-based BLE proxy client — the reference implementation of the hardware side of the BLE proxy protocol. + * + * Connects out to a hub hosting {@link BleProxyHandler} (for example the matter-server `/ble` endpoint) and executes + * the proxied BLE operations against a local Bluetooth adapter via Noble. It doubles as a standalone BLE bridge and + * as an integration testing tool. + */ + +import { + Bytes, + Duration, + Environment, + errorOf, + ImplementationError, + Logger, + Observable, + PromiseTimeoutError, + WsProxyCommandError, + WsProxyConnection, + Seconds, + WebSocketClient, + withTimeout, +} from "@matter/general"; +import "@matter/nodejs-ws"; +import type { Characteristic, Noble, Peripheral, Service } from "@stoprocent/noble"; +import { + BLE_PROXY_PROTOCOL_VERSION, + BinaryFrameOpcode, + BleProxyCommand, + BleProxyErrorCode, + BleProxyEvent, + type BinaryFrame, + type BleProxyEventName, + type ConnectResult, + type DeviceDiscoveredData, + type DiscoverCharacteristicsResult, + type DiscoverServicesResult, + type ReadCharacteristicResult, + type RequestMtuResult, +} from "../BleProxyProtocol.js"; + +const logger = Logger.get("NobleBleProxyClient"); + +/** Matter's BTP service. Both scanning and the connect-time interview are limited to it. */ +const MATTER_SERVICE_UUID = "fff6"; + +const INTERVIEW_TIMEOUT = Seconds(30); +const LAZY_DISCOVERY_TIMEOUT = Seconds(10); +const ADAPTER_POWER_ON_TIMEOUT = Seconds(10); + +/** Connection handles travel in the binary frame's two-byte handle field. */ +const MAX_CONNECTION_HANDLE = 0xffff; + +type NobleFactory = (options: { extended: boolean }) => Noble; + +type NotificationListener = (data: Buffer) => void; + +interface Subscription { + characteristic: Characteristic; + listener: NotificationListener; +} + +interface ConnectionState { + peripheral: Peripheral; + services: Map; + characteristics: Map; + subscriptions: Map; + lastWriteCharacteristic?: Characteristic; + + /** + * Serializes writes. Noble registers each write's completion with `onceExclusive`, so overlapping writes on + * one characteristic drop all but the last callback and their errors vanish. + */ + writes: Promise; +} + +/** Fields that, when changed, justify re-emitting a device_discovered event. */ +interface DiscoverFingerprint { + name: string; + connectable: boolean; + serviceUuids: string; + serviceData: string; +} + +/** Some noble builds export a factory function rather than a ready-made instance. */ +function nobleInstanceOf(nobleExport: Noble | NobleFactory): Noble { + return typeof nobleExport === "function" ? nobleExport({ extended: false }) : nobleExport; +} + +function decodeBase64(value: string): Buffer { + return Buffer.from(Bytes.of(Bytes.fromBase64(value))); +} + +function timeoutAfter(promise: Promise, timeout: Duration, message: string): Promise { + return withTimeout(timeout, promise, () => { + throw new PromiseTimeoutError(message); + }); +} + +function requireString(args: Record, key: string): string { + const value = args[key]; + if (typeof value !== "string") { + throw new WsProxyCommandError(BleProxyErrorCode.InternalError, `Command argument ${key} must be a string`); + } + return value; +} + +function requireNumber(args: Record, key: string): number { + const value = args[key]; + if (typeof value !== "number") { + throw new WsProxyCommandError(BleProxyErrorCode.InternalError, `Command argument ${key} must be a number`); + } + return value; +} + +function optionalFlag(args: Record, key: string): boolean { + const value = args[key]; + if (value === undefined) { + return false; + } + if (typeof value !== "boolean") { + throw new WsProxyCommandError(BleProxyErrorCode.InternalError, `Command argument ${key} must be a boolean`); + } + return value; +} + +export class NobleBleProxyClient { + readonly #serverUrl: string; + readonly #hciId?: number; + readonly #environment: Environment; + #connection?: WsProxyConnection; + #noble?: Noble; + #connections = new Map(); + #nextHandle = 1; + #discoveredPeripherals = new Map(); + #lastDiscoverFingerprint = new Map(); + #started = false; + #closing = false; + /** Hub's last commanded scan state, independent of whether noble is actually scanning right now. */ + #hubScanRequested = false; + /** Increments per scan command so a slow start_scan cannot roll back the intent of a newer one. */ + #scanCommandEpoch = 0; + #closePromise?: Promise; + #nobleWarningListener?: (message: string) => void; + #nobleStateListener?: (state: string) => void; + #nobleDiscoverListener?: (peripheral: Peripheral) => void; + + /** Emitted once the connection to the hub is gone, whether we closed it or the hub did. */ + readonly closed = new Observable<[]>(error => logger.error("Observer failed:", error)); + + constructor(options: NobleBleProxyClient.Options) { + this.#serverUrl = options.serverUrl; + this.#hciId = options.hciId; + this.#environment = options.environment ?? Environment.default; + } + + /** True once the handshake completed and until the connection closes. */ + get connected(): boolean { + return this.#connection?.connected ?? false; + } + + /** + * Open the local Bluetooth adapter, connect to the hub, and complete the protocol handshake. + */ + async connect(): Promise { + // One socket per instance: a second connect would abandon the previous WsProxyConnection and stack another + // pair of listeners on noble's process-wide singleton. The sentinel is set before the first await so + // concurrent callers cannot both pass the check. + if (this.#started) { + throw new ImplementationError( + "This client has already been connected; construct a new one to reconnect to the hub", + ); + } + this.#started = true; + + await this.#loadNoble(); + + const connection = await this.#environment.get(WebSocketClient).connect(this.#serverUrl); + + const proxy = new WsProxyConnection({ + connection, + version: BLE_PROXY_PROTOCOL_VERSION, + role: "initiator", + idPrefix: "nbl", + }); + this.#connection = proxy; + + proxy.setCommandHandler((command, args) => this.#invokeCommand(command, args ?? {})); + proxy.frameReceived.on(frame => this.#receiveFrame(frame)); + proxy.closed.on(() => { + if (!this.#closing) { + logger.info("Disconnected from hub"); + } + this.closed.emit(); + }); + + proxy.start(); + await proxy.opened(); + } + + /** + * Disconnect every peripheral, stop the adapter and close the hub connection. Safe to call repeatedly and + * concurrently; noble's `stop()` must not run twice. + */ + close(): Promise { + this.#closing = true; + return (this.#closePromise ??= this.#close()); + } + + async #close(): Promise { + for (const [handle, conn] of this.#connections) { + if (conn.peripheral.state === "connected") { + // Not awaited: noble's disconnect can stall on a wedged adapter and shutdown must still complete + conn.peripheral + .disconnectAsync() + .catch(error => logger.warn(`[CONN] handle=${handle} disconnect during shutdown failed:`, error)); + } + } + this.#connections.clear(); + + // Noble is a process-wide singleton, so listeners left behind outlive this client + const noble = this.#noble; + if (noble !== undefined) { + if (this.#nobleWarningListener !== undefined) { + noble.removeListener("warning", this.#nobleWarningListener); + this.#nobleWarningListener = undefined; + } + if (this.#nobleStateListener !== undefined) { + noble.removeListener("stateChange", this.#nobleStateListener); + this.#nobleStateListener = undefined; + } + if (this.#nobleDiscoverListener !== undefined) { + noble.removeListener("discover", this.#nobleDiscoverListener); + this.#nobleDiscoverListener = undefined; + } + } + + try { + noble?.stop(); + } catch (error) { + // Noble's stop misbehaves when the adapter is not powered on, and it must not strand the socket close + // that emits `closed` + logger.warn("[NOBLE] stop failed:", error); + } + + await this.#connection?.close(); + } + + async #loadNoble(): Promise { + if (this.#hciId !== undefined) { + process.env.NOBLE_HCI_DEVICE_ID = this.#hciId.toString(); + } + + const noble = nobleInstanceOf((await import("@stoprocent/noble")).default); + this.#noble = noble; + + // Noble's own warnings (unknown peripheral, missing service, …) are only visible here; the proxy runs in a + // different process than the matter.js node that would otherwise surface them + this.#nobleWarningListener = (message: string) => logger.warn(`[NOBLE] warning: ${message}`); + this.#nobleStateListener = (state: string) => logger.info(`[NOBLE] stateChange: ${state}`); + noble.on("warning", this.#nobleWarningListener); + noble.on("stateChange", this.#nobleStateListener); + + // The hub pushes start_scan as soon as the handshake completes, and noble rejects scanning outright when + // the adapter is not powered on — with no retry, that leaves this client blind for the whole scan + try { + await noble.waitForPoweredOnAsync(ADAPTER_POWER_ON_TIMEOUT); + } catch (error) { + logger.warn( + `[NOBLE] adapter not powered on after ${Duration.format(ADAPTER_POWER_ON_TIMEOUT)}; scanning will fail until it is:`, + error, + ); + } + } + + // ─── Command Dispatch ──────────────────────────────────────────────────── + + async #invokeCommand(command: string, args: Record): Promise | void> { + logger.debug(`[←CMD] ${command}${Object.keys(args).length ? ` ${JSON.stringify(args)}` : ""}`); + + switch (command) { + case BleProxyCommand.StartScan: + return this.#handleStartScan(); + + case BleProxyCommand.StopScan: + return this.#handleStopScan(); + + case BleProxyCommand.Connect: + return this.#handleConnect(requireString(args, "address")); + + case BleProxyCommand.Disconnect: + return this.#handleDisconnect(requireNumber(args, "connection_handle")); + + case BleProxyCommand.DiscoverServices: + return this.#handleDiscoverServices(requireNumber(args, "connection_handle")); + + case BleProxyCommand.DiscoverCharacteristics: + return this.#handleDiscoverCharacteristics( + requireNumber(args, "connection_handle"), + requireString(args, "service_uuid"), + ); + + case BleProxyCommand.ReadCharacteristic: + return this.#handleReadCharacteristic( + requireNumber(args, "connection_handle"), + requireString(args, "characteristic_uuid"), + ); + + case BleProxyCommand.WriteCharacteristic: + return this.#handleWriteCharacteristic( + requireNumber(args, "connection_handle"), + requireString(args, "characteristic_uuid"), + requireString(args, "value"), + optionalFlag(args, "response"), + ); + + case BleProxyCommand.SubscribeCharacteristic: + return this.#handleSubscribeCharacteristic( + requireNumber(args, "connection_handle"), + requireString(args, "characteristic_uuid"), + ); + + case BleProxyCommand.WriteAndSubscribe: + return this.#handleWriteAndSubscribe( + requireNumber(args, "connection_handle"), + requireString(args, "write_uuid"), + requireString(args, "write_value"), + optionalFlag(args, "write_response"), + requireString(args, "subscribe_uuid"), + ); + + case BleProxyCommand.UnsubscribeCharacteristic: + return this.#handleUnsubscribeCharacteristic( + requireNumber(args, "connection_handle"), + requireString(args, "characteristic_uuid"), + ); + + case BleProxyCommand.RequestMtu: + return this.#handleRequestMtu(requireNumber(args, "connection_handle"), requireNumber(args, "mtu")); + + default: + throw new WsProxyCommandError(BleProxyErrorCode.InternalError, `Unknown command: ${command}`); + } + } + + // ─── Command Handlers ──────────────────────────────────────────────────── + + async #handleStartScan(): Promise { + const noble = this.#noble; + if (!noble) { + throw new WsProxyCommandError(BleProxyErrorCode.BluetoothUnavailable, "Noble not initialized"); + } + + this.#lastDiscoverFingerprint.clear(); + // Remove only the listener we installed; a repeated scan would otherwise emit each advertisement more than once + if (this.#nobleDiscoverListener !== undefined) { + noble.removeListener("discover", this.#nobleDiscoverListener); + } + this.#nobleDiscoverListener = (peripheral: Peripheral) => this.#onDiscover(peripheral); + noble.on("discover", this.#nobleDiscoverListener); + + // Commands are dispatched concurrently, so a stop_scan arriving while the radio is still starting must see + // the intent this command establishes + const epoch = ++this.#scanCommandEpoch; + this.#hubScanRequested = true; + try { + await noble.startScanningAsync([MATTER_SERVICE_UUID], true); + } catch (error) { + // The command failed, so the hub does not believe scanning is active; a later connect must not + // resume it on the hub's behalf. A newer scan command owns the intent, so only the newest may clear it. + if (this.#scanCommandEpoch === epoch) { + this.#hubScanRequested = false; + } + throw error; + } + + if (!this.#hubScanRequested) { + try { + await noble.stopScanningAsync(); + } catch (error) { + logger.warn("[SCAN] failed to stop scanning after a concurrent stop_scan:", error); + } + logger.info("[SCAN] BLE scan started and immediately stopped by a concurrent stop_scan"); + return; + } + + logger.info(`[SCAN] BLE scan started (filter: ${MATTER_SERVICE_UUID})`); + } + + async #handleStopScan(): Promise { + this.#scanCommandEpoch++; + this.#hubScanRequested = false; + await this.#noble?.stopScanningAsync(); + logger.info("[SCAN] BLE scan stopped"); + } + + async #handleConnect(address: string) { + const peripheral = this.#discoveredPeripherals.get(address); + if (!peripheral) { + logger.error( + `[CONN] No peripheral found for address "${address}". Known: ${[...this.#discoveredPeripherals.keys()].join(", ")}`, + ); + throw new WsProxyCommandError(BleProxyErrorCode.DeviceNotFound, `No device found for address ${address}`); + } + + const noble = this.#noble; + if (!noble) { + throw new WsProxyCommandError(BleProxyErrorCode.BluetoothUnavailable, "Noble not initialized"); + } + + const handle = this.#allocateHandle(); + const connState: ConnectionState = { + peripheral, + services: new Map(), + characteristics: new Map(), + subscriptions: new Map(), + writes: Promise.resolve(), + }; + this.#connections.set(handle, connState); + + // Track disconnect at every stage so unexpected drops are surfaced rather than silently hanging the awaiting + // noble promise + let disconnectedReason: string | undefined; + const disconnectListener = () => { + disconnectedReason = `peripheral disconnected (state=${peripheral.state})`; + logger.info(`[CONN] Peripheral handle=${handle} disconnected (state=${peripheral.state})`); + this.#connections.delete(handle); + this.#sendEvent(BleProxyEvent.Disconnected, { connection_handle: handle }); + }; + peripheral.once("disconnect", disconnectListener); + + let mtu: number; + + logger.info(`[CONN] Connecting to "${address}" (state=${peripheral.state})...`); + try { + // Pause scanning during connect + GATT discovery. On macOS, scanning concurrently with + // `service.discoverCharacteristicsAsync` causes the CoreBluetooth delegate callback to never fire; the + // peripheral stays connected but discovery hangs. + logger.debug("[SCAN] pausing scan for connect+interview..."); + await noble.stopScanningAsync(); + + await peripheral.connectAsync(); + logger.info(`[CONN] Connected handle=${handle} state=${peripheral.state} mtu=${peripheral.mtu ?? "?"}`); + + logger.debug(`[GATT] handle=${handle} discoverServicesAsync(["${MATTER_SERVICE_UUID}"])...`); + const services = await timeoutAfter( + peripheral.discoverServicesAsync([MATTER_SERVICE_UUID]), + INTERVIEW_TIMEOUT, + `discoverServices(${MATTER_SERVICE_UUID}) timed out after ${Duration.format(INTERVIEW_TIMEOUT)}`, + ); + logger.debug( + `[GATT] handle=${handle} services: ${services.map(s => s.uuid).join(", ")} state=${peripheral.state}`, + ); + + for (const service of services) { + connState.services.set(service.uuid, service); + if (service.uuid !== MATTER_SERVICE_UUID) continue; + logger.debug(`[GATT] handle=${handle} discoverCharacteristicsAsync() on ${service.uuid}...`); + const chars = await timeoutAfter( + service.discoverCharacteristicsAsync(), + INTERVIEW_TIMEOUT, + `discoverCharacteristics(${service.uuid}) timed out after ${Duration.format(INTERVIEW_TIMEOUT)}`, + ); + for (const char of chars) { + connState.characteristics.set(char.uuid, char); + } + logger.debug( + `[GATT] handle=${handle} chars on ${service.uuid}: ${chars.map(c => c.uuid).join(", ")} state=${peripheral.state}`, + ); + } + + mtu = peripheral.mtu ?? 23; + logger.info(`[GATT] handle=${handle} ready mtu=${mtu}`); + } catch (error) { + const reason = disconnectedReason ?? errorOf(error).message; + logger.error(`[CONN] handle=${handle} failed: ${reason}`); + this.#connections.delete(handle); + peripheral.removeListener("disconnect", disconnectListener); + if (peripheral.state === "connected") { + peripheral + .disconnectAsync() + .catch(disconnectError => + logger.warn(`[CONN] handle=${handle} cleanup disconnect failed:`, disconnectError), + ); + } + await this.#resumeScanIfRequested(noble, "after connect failure"); + throw new WsProxyCommandError(BleProxyErrorCode.InternalError, reason); + } + + // Resume scanning outside the connect try: a scan failure here must not tear down an interviewed + // connection, and the hub may have changed its mind about scanning while this was in flight + await this.#resumeScanIfRequested(noble, "after connect+interview"); + + return { connection_handle: handle, mtu } satisfies ConnectResult; + } + + /** + * Resume scanning after the connect-time pause, but only if the hub still wants it — a stop_scan received + * during the pause, or while the resume itself was in flight, must stay in effect. Never throws: a scan + * failure here is logged, not surfaced to the command that triggered the resume. + */ + async #resumeScanIfRequested(noble: Noble, context: string): Promise { + if (!this.#hubScanRequested) { + return; + } + try { + logger.debug(`[SCAN] resuming scan ${context}...`); + await noble.startScanningAsync([MATTER_SERVICE_UUID], true); + if (!this.#hubScanRequested) { + await noble.stopScanningAsync(); + } + } catch (error) { + logger.warn(`[SCAN] failed to resume scanning ${context}:`, error); + } + } + + async #handleDisconnect(connectionHandle: number): Promise { + const conn = this.#requireConnection(connectionHandle); + + if (conn.peripheral.state === "connected") { + await conn.peripheral.disconnectAsync(); + } + this.#connections.delete(connectionHandle); + } + + async #handleDiscoverServices(connectionHandle: number) { + const conn = this.#requireConnection(connectionHandle); + + if (conn.services.size > 0) { + const uuids = [...conn.services.keys()]; + logger.debug(`[GATT] handle=${connectionHandle} services from cache: ${uuids.join(", ")}`); + return { services: uuids.map(uuid => ({ uuid })) } satisfies DiscoverServicesResult; + } + + logger.debug(`[GATT] handle=${connectionHandle} discovering services (lazy)...`); + const services = await timeoutAfter( + conn.peripheral.discoverServicesAsync([]), + LAZY_DISCOVERY_TIMEOUT, + `discoverServices timed out after ${Duration.format(LAZY_DISCOVERY_TIMEOUT)}`, + ); + for (const service of services) { + conn.services.set(service.uuid, service); + } + logger.debug(`[GATT] handle=${connectionHandle} discovered services: ${services.map(s => s.uuid).join(", ")}`); + + return { services: services.map(s => ({ uuid: s.uuid })) } satisfies DiscoverServicesResult; + } + + async #handleDiscoverCharacteristics(connectionHandle: number, serviceUuid: string) { + const conn = this.#requireConnection(connectionHandle); + + const service = conn.services.get(serviceUuid); + if (!service) { + throw new WsProxyCommandError(BleProxyErrorCode.ServiceNotFound, `Service ${serviceUuid} not found`); + } + + const cachedChars = service.characteristics ?? []; + if (cachedChars.length > 0) { + logger.debug( + `[GATT] handle=${connectionHandle} characteristics from cache for ${serviceUuid}: ` + + cachedChars.map(c => `${c.uuid}[${c.properties.join(",")}]`).join(", "), + ); + return { + characteristics: cachedChars.map(c => ({ uuid: c.uuid, properties: c.properties })), + } satisfies DiscoverCharacteristicsResult; + } + + logger.debug(`[GATT] handle=${connectionHandle} discovering characteristics for ${serviceUuid} (lazy)...`); + const characteristics = await timeoutAfter( + service.discoverCharacteristicsAsync([]), + LAZY_DISCOVERY_TIMEOUT, + `discoverCharacteristics(${serviceUuid}) timed out after ${Duration.format(LAZY_DISCOVERY_TIMEOUT)}`, + ); + for (const char of characteristics) { + conn.characteristics.set(char.uuid, char); + } + logger.debug( + `[GATT] handle=${connectionHandle} discovered chars for ${serviceUuid}: ` + + characteristics.map(c => `${c.uuid}[${c.properties.join(",")}]`).join(", "), + ); + + return { + characteristics: characteristics.map(c => ({ uuid: c.uuid, properties: c.properties })), + } satisfies DiscoverCharacteristicsResult; + } + + async #handleReadCharacteristic(connectionHandle: number, characteristicUuid: string) { + const conn = this.#requireConnection(connectionHandle); + const char = this.#requireCharacteristic(conn, characteristicUuid); + + const data = await char.readAsync(); + logger.debug(`[GATT] read ${characteristicUuid} → ${data.length} bytes`); + return { value: Bytes.toBase64(data) } satisfies ReadCharacteristicResult; + } + + async #handleWriteCharacteristic( + connectionHandle: number, + characteristicUuid: string, + value: string, + withResponse: boolean, + ): Promise { + const conn = this.#requireConnection(connectionHandle); + const char = this.#requireCharacteristic(conn, characteristicUuid); + + const data = decodeBase64(value); + logger.debug(`[GATT] write ${characteristicUuid} ${data.length} bytes withResponse=${withResponse}`); + await this.#write(conn, char, data, !withResponse); + conn.lastWriteCharacteristic = char; + } + + async #handleSubscribeCharacteristic(connectionHandle: number, characteristicUuid: string): Promise { + const conn = this.#requireConnection(connectionHandle); + const char = this.#requireCharacteristic(conn, characteristicUuid); + + const listener = this.#forwardNotifications(conn, connectionHandle, characteristicUuid, char); + + try { + await char.subscribeAsync(); + } catch (error) { + char.removeListener("data", listener); + throw error; + } + conn.subscriptions.set(characteristicUuid, { characteristic: char, listener }); + logger.debug(`[GATT] subscribe ${characteristicUuid} handle=${connectionHandle}`); + } + + /** + * Write and subscribe without an intervening round-trip to the hub, so a peripheral that indicates immediately + * after the Write Response — as Matter's BTP handshake does on C2 — cannot fire before notifications are enabled. + */ + async #handleWriteAndSubscribe( + connectionHandle: number, + writeUuid: string, + writeValue: string, + writeResponse: boolean, + subscribeUuid: string, + ): Promise { + const conn = this.#requireConnection(connectionHandle); + const writeChar = this.#requireCharacteristic(conn, writeUuid); + const subscribeChar = this.#requireCharacteristic(conn, subscribeUuid); + + // Notifications are forwarded from the moment the write goes out; an indication that arrives before + // subscribeAsync resolves is still delivered + const listener = this.#forwardNotifications(conn, connectionHandle, subscribeUuid, subscribeChar); + + const data = decodeBase64(writeValue); + logger.debug( + `[GATT] write ${writeUuid} ${data.length} bytes withResponse=${writeResponse} + subscribe ${subscribeUuid} handle=${connectionHandle}`, + ); + + try { + await this.#write(conn, writeChar, data, !writeResponse); + } catch (error) { + subscribeChar.removeListener("data", listener); + throw new WsProxyCommandError( + BleProxyErrorCode.WriteFailed, + `write(${writeUuid}): ${errorOf(error).message}`, + ); + } + conn.lastWriteCharacteristic = writeChar; + + try { + await subscribeChar.subscribeAsync(); + } catch (error) { + subscribeChar.removeListener("data", listener); + throw new WsProxyCommandError( + BleProxyErrorCode.SubscribeFailed, + `subscribe(${subscribeUuid}): ${errorOf(error).message}`, + ); + } + conn.subscriptions.set(subscribeUuid, { characteristic: subscribeChar, listener }); + } + + async #handleUnsubscribeCharacteristic(connectionHandle: number, characteristicUuid: string): Promise { + const conn = this.#requireConnection(connectionHandle); + + const subscription = conn.subscriptions.get(characteristicUuid); + if (!subscription) { + throw new WsProxyCommandError(BleProxyErrorCode.NotSubscribed, `Not subscribed to ${characteristicUuid}`); + } + + await subscription.characteristic.unsubscribeAsync(); + subscription.characteristic.removeListener("data", subscription.listener); + conn.subscriptions.delete(characteristicUuid); + logger.debug(`[GATT] unsubscribe ${characteristicUuid} handle=${connectionHandle}`); + } + + async #handleRequestMtu(connectionHandle: number, mtu: number) { + const conn = this.#requireConnection(connectionHandle); + + // Noble has no explicit MTU request; report what the peripheral negotiated + const actualMtu = conn.peripheral.mtu ?? mtu; + logger.debug(`[GATT] request_mtu handle=${connectionHandle} requested=${mtu} actual=${actualMtu}`); + return { mtu: actualMtu } satisfies RequestMtuResult; + } + + // ─── Noble Events ──────────────────────────────────────────────────────── + + #onDiscover(peripheral: Peripheral): void { + // On macOS, peripheral.address is often empty — fall back to peripheral.id (UUID) + const address = peripheral.address || peripheral.id; + this.#discoveredPeripherals.set(address, peripheral); + + const serviceData: Record = {}; + for (const sd of peripheral.advertisement.serviceData ?? []) { + serviceData[sd.uuid] = Bytes.toBase64(sd.data); + } + + const name = peripheral.advertisement.localName ?? "(unnamed)"; + const connectable = peripheral.connectable ?? false; + const serviceUuids = peripheral.advertisement.serviceUuids ?? []; + + const fingerprint: DiscoverFingerprint = { + name, + connectable, + serviceUuids: serviceUuids.join(","), + serviceData: Object.entries(serviceData) + .map(([uuid, data]) => `${uuid}=${data}`) + .sort() + .join("|"), + }; + + const prev = this.#lastDiscoverFingerprint.get(address); + const changed = + !prev || + prev.name !== fingerprint.name || + prev.connectable !== fingerprint.connectable || + prev.serviceUuids !== fingerprint.serviceUuids || + prev.serviceData !== fingerprint.serviceData; + + if (!changed) { + return; + } + this.#lastDiscoverFingerprint.set(address, fingerprint); + + logger.debug( + `[EVT] device_discovered addr=${address} name="${name}" rssi=${peripheral.rssi}` + + ` services=${JSON.stringify(serviceUuids)}` + + ` serviceData=${JSON.stringify(Object.keys(serviceData))}`, + ); + + const event = { + address, + name: peripheral.advertisement.localName, + rssi: peripheral.rssi, + connectable, + service_data: serviceData, + service_uuids: serviceUuids, + } satisfies DeviceDiscoveredData; + + this.#sendEvent(BleProxyEvent.DeviceDiscovered, event); + } + + /** + * Install a notification forwarder, replacing any forwarder this connection already has for the + * characteristic — stacked listeners would deliver every notification to the hub more than once. Returns the + * listener so a caller that fails afterwards removes exactly its own. + */ + #forwardNotifications( + conn: ConnectionState, + connectionHandle: number, + characteristicUuid: string, + char: Characteristic, + ): NotificationListener { + const previous = conn.subscriptions.get(characteristicUuid); + if (previous) { + previous.characteristic.removeListener("data", previous.listener); + // The entry is only restored once the new subscribe succeeds, so a failure cannot leave the map + // pointing at a listener that no longer receives anything + conn.subscriptions.delete(characteristicUuid); + } + + const listener = (data: Buffer) => { + logger.debug(`[GATT] notify ${characteristicUuid} handle=${connectionHandle} ${data.length} bytes`); + this.#sendFrame(BinaryFrameOpcode.Notification, connectionHandle, new Uint8Array(data)); + }; + char.on("data", listener); + return listener; + } + + // ─── Hub Traffic ───────────────────────────────────────────────────────── + + #receiveFrame(frame: BinaryFrame): void { + if (frame.opcode !== BinaryFrameOpcode.WriteData) { + return; + } + + const conn = this.#connections.get(frame.handle); + const characteristic = conn?.lastWriteCharacteristic; + if (!conn || !characteristic) { + logger.warn(`[←BIN] WriteData: no lastWriteCharacteristic for handle=${frame.handle}`); + return; + } + + // Matter BTP writes C1 with an ATT Write Request, so withoutResponse is false + this.#write(conn, characteristic, Buffer.from(frame.payload), false).catch(error => + logger.error("Binary write error:", error), + ); + } + + #sendEvent(event: BleProxyEventName, data: Record): void { + const connection = this.#connection; + if (!connection?.connected) { + logger.debug(`Dropping event ${event}, connection is not open`); + return; + } + connection.sendEvent(event, data); + } + + #sendFrame(opcode: number, connectionHandle: number, payload: Uint8Array): void { + const connection = this.#connection; + if (!connection?.connected) { + logger.debug(`Dropping frame opcode=${opcode} handle=${connectionHandle}, connection is not open`); + return; + } + try { + connection.sendFrame(opcode, connectionHandle, payload); + } catch (error) { + // Notifications arrive on a noble listener, where a throw would surface as an uncaught exception + logger.error(`Failed to send frame opcode=${opcode} handle=${connectionHandle}:`, error); + } + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + /** + * Perform a GATT write as part of the connection's write chain, so no two writes are ever in flight on the + * same peripheral. The returned promise carries the write's own outcome; the chain itself absorbs it. + */ + #write(conn: ConnectionState, char: Characteristic, data: Buffer, withoutResponse: boolean): Promise { + const write = conn.writes.then(() => char.writeAsync(data, withoutResponse)); + conn.writes = write.catch(() => {}); + return write; + } + + /** + * Allocate a connection handle within the two-byte range the binary frame header carries, skipping handles still + * in use so a wrap cannot address a live connection. + */ + #allocateHandle(): number { + if (this.#connections.size >= MAX_CONNECTION_HANDLE) { + throw new WsProxyCommandError( + BleProxyErrorCode.InternalError, + `All ${MAX_CONNECTION_HANDLE} connection handles are in use`, + ); + } + + let handle = this.#nextHandle; + while (this.#connections.has(handle)) { + handle = handle === MAX_CONNECTION_HANDLE ? 1 : handle + 1; + } + this.#nextHandle = handle === MAX_CONNECTION_HANDLE ? 1 : handle + 1; + + return handle; + } + + #requireConnection(connectionHandle: number): ConnectionState { + const conn = this.#connections.get(connectionHandle); + if (!conn) { + throw new WsProxyCommandError( + BleProxyErrorCode.NotConnected, + `No connection with handle ${connectionHandle}`, + ); + } + return conn; + } + + #requireCharacteristic(conn: ConnectionState, uuid: string): Characteristic { + // Noble keys characteristics dash-free and lowercase; the hub may send the dashed 128-bit form + const char = + conn.characteristics.get(uuid) ?? + conn.characteristics.get(uuid.toLowerCase()) ?? + conn.characteristics.get(uuid.toUpperCase().replace(/-/g, "").toLowerCase()); + if (!char) { + throw new WsProxyCommandError(BleProxyErrorCode.CharacteristicNotFound, `Characteristic ${uuid} not found`); + } + return char; + } +} + +export namespace NobleBleProxyClient { + export interface Options { + /** WebSocket URL of the hub's BLE proxy endpoint, e.g. `ws://localhost:5580/ble`. */ + serverUrl: string; + + /** Bluetooth adapter to bind, e.g. 0 for hci0. Linux only; the platform default is used when unset. */ + hciId?: number; + + /** Environment supplying the {@link WebSocketClient}. Defaults to {@link Environment.default}. */ + environment?: Environment; + } +} diff --git a/packages/ws-ble/src/noble-client/cli.ts b/packages/ws-ble/src/noble-client/cli.ts new file mode 100644 index 0000000000..99a25c1ac0 --- /dev/null +++ b/packages/ws-ble/src/noble-client/cli.ts @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Noble-based BLE proxy client CLI. + * + * Connects to a hub's BLE proxy WebSocket endpoint and proxies BLE operations to a local Bluetooth adapter. + * + * Usage: + * matter-ble-proxy --server ws://localhost:5580/ble [--hci-id 0] + */ + +import { Logger } from "@matter/general"; +import { NobleBleProxyClient } from "./NobleBleProxyClient.js"; + +const logger = Logger.get("matter-ble-proxy"); + +const USAGE = `Noble BLE proxy client - reference implementation + +Usage: matter-ble-proxy --server [options] + +Options: + --server BLE proxy WebSocket URL of the hub, e.g. ws://localhost:5580/ble (required) + --hci-id Bluetooth adapter HCI ID, e.g. 0 for hci0 (Linux only) + --help, -h Show this help`; + +function fail(message: string): never { + process.stderr.write(`${message}\n\n${USAGE}\n`); + process.exit(1); +} + +function parseArgs(argv: string[]) { + let serverUrl: string | undefined; + let hciId: number | undefined; + + for (let i = 0; i < argv.length; i++) { + switch (argv[i]) { + case "--server": + serverUrl = argv[++i]; + if (serverUrl === undefined) { + fail("--server requires a WebSocket URL"); + } + break; + + case "--hci-id": { + const value = argv[++i]; + if (value === undefined) { + fail("--hci-id requires an adapter ID"); + } + hciId = Number.parseInt(value, 10); + if (Number.isNaN(hciId)) { + fail(`--hci-id must be a number, got "${value}"`); + } + break; + } + + case "--help": + case "-h": + process.stdout.write(`${USAGE}\n`); + process.exit(0); + break; + + default: + fail(`Unknown argument "${argv[i]}"`); + } + } + + if (serverUrl === undefined) { + fail("--server is required"); + } + + return { serverUrl, hciId }; +} + +async function main() { + const { serverUrl, hciId } = parseArgs(process.argv.slice(2)); + const client = new NobleBleProxyClient({ serverUrl, hciId }); + + // The proxy is only useful while the hub is reachable; exiting lets a supervisor restart and reconnect it + const stopped = new Promise(resolve => client.closed.on(() => resolve())); + + let connecting = true; + let requested = false; + const shutdown = (signal: string, code: number) => { + // Nothing is worth draining before the hub connection exists, and a close that cannot finish must not + // make the process unkillable + if (connecting || requested) { + logger.warn(`Received ${signal}, exiting immediately`); + process.exit(code); + } + requested = true; + logger.info(`Received ${signal}, shutting down...`); + client.close().catch(error => logger.error("Error during shutdown:", error)); + }; + + process.on("SIGINT", () => shutdown("SIGINT", 130)); + process.on("SIGTERM", () => shutdown("SIGTERM", 143)); + + logger.info(`Connecting to ${serverUrl}...`); + try { + await client.connect(); + } catch (error) { + logger.error(`Failed to connect to ${serverUrl}:`, error); + logger.notice( + "The hub must be reachable and expose the BLE proxy WebSocket endpoint" + + " (matter-server must run with --ble-proxy)", + ); + return 1; + } + connecting = false; + logger.info("Connected. BLE proxy active. Press Ctrl+C to stop."); + + // The hub may drop us between the handshake and the observer above, which would never emit again + if (client.connected) { + await stopped; + } + + await client.close(); + + // Losing the hub is a failure of the proxy's purpose, so supervisors configured to restart on failure do + return requested ? 0 : 1; +} + +main().then( + code => process.exit(code), + error => { + logger.error("BLE proxy failed:", error); + process.exit(1); + }, +); diff --git a/packages/ws-ble/src/noble-client/index.ts b/packages/ws-ble/src/noble-client/index.ts new file mode 100644 index 0000000000..b86cd63cdb --- /dev/null +++ b/packages/ws-ble/src/noble-client/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./NobleBleProxyClient.js"; diff --git a/packages/ws-ble/src/tsconfig.json b/packages/ws-ble/src/tsconfig.json new file mode 100644 index 0000000000..222ee6a584 --- /dev/null +++ b/packages/ws-ble/src/tsconfig.json @@ -0,0 +1,30 @@ +// Managed by nacho-build. Nacho will update references automatically but otherwise preserves your edits. +// Use `nacho-build configure` to overwrite with defaults. +{ + "extends": "../../../tsc/tsconfig.lib.json", + "compilerOptions": { + "types": [ + "node" + ] + }, + "references": [ + { + "path": "../../general/src" + }, + { + "path": "../../node/src" + }, + { + "path": "../../nodejs-ws/src" + }, + { + "path": "../../nodejs/src" + }, + { + "path": "../../protocol/src" + }, + { + "path": "../../testing/src" + } + ] +} diff --git a/packages/ws-ble/test/BleProxyConnectionTest.ts b/packages/ws-ble/test/BleProxyConnectionTest.ts new file mode 100644 index 0000000000..120307a7b5 --- /dev/null +++ b/packages/ws-ble/test/BleProxyConnectionTest.ts @@ -0,0 +1,293 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Bytes, + MockWsConnection, + NetworkError, + WsProxyCommandError, + WsProxyConnectionClosedError, + type Observable, +} from "@matter/general"; +import { BleProxyConnection } from "../src/BleProxyConnection.js"; +import { BinaryFrameOpcode, BLE_PROXY_PROTOCOL_VERSION, BleProxyCommand } from "../src/BleProxyProtocol.js"; + +const VERSION = BLE_PROXY_PROTOCOL_VERSION; + +const { send, receive } = MockWsConnection; + +/** + * Create a connected {@link BleProxyConnection} plus the mock client side of its transport. + */ +async function connect() { + const { client, server } = MockWsConnection(); + const connection = new BleProxyConnection(server); + + await send(client, { type: "hello", version: VERSION }); + expect(await receive(client)).deep.equals({ type: "hello_response", version: VERSION }); + expect(connection.connected).true; + + return { client, connection }; +} + +function settlement(promise: Promise) { + return promise.then( + () => undefined, + (error: unknown) => error, + ); +} + +function nextEmit(observable: Observable<[]>) { + return new Promise(resolve => observable.on(() => resolve())); +} + +/** + * Assert a settled value is an error of the expected type and narrow it for further assertions. + */ +function errorOfType(value: unknown, type: new (...args: never[]) => T) { + expect(value).instanceOf(type); + if (!(value instanceof type)) { + throw new NetworkError(`Expected ${type.name}`); + } + return value; +} + +describe("BleProxyConnection", () => { + before(() => MockTime.enable()); + + it("reports connected after handshake", async () => { + const { connection } = await connect(); + + expect(connection.connected).true; + + await connection.close(); + }); + + it("opened resolves once the handshake completes", async () => { + const { client, server } = MockWsConnection(); + const connection = new BleProxyConnection(server); + + const opened = connection.opened(); + + await send(client, { type: "hello", version: VERSION }); + await opened; + + expect(connection.connected).true; + + await connection.close(); + }); + + it("emits handshakeCompleted once the handshake completes", async () => { + const { client, server } = MockWsConnection(); + const connection = new BleProxyConnection(server); + const completed = nextEmit(connection.handshakeCompleted); + + await send(client, { type: "hello", version: VERSION }); + await completed; + + expect(connection.connected).true; + + await connection.close(); + }); + + it("rejects opened() when the connection closes before the handshake completes", async () => { + const { client, server } = MockWsConnection(); + const connection = new BleProxyConnection(server); + + const opened = settlement(connection.opened()); + + await send(client, { type: "something-else" }); + + errorOfType(await opened, WsProxyConnectionClosedError); + expect(connection.connected).false; + + await connection.close(); + }); + + it("rejects a version mismatch and reports the supported version", async () => { + const { client, server } = MockWsConnection(); + const connection = new BleProxyConnection(server); + const closed = nextEmit(connection.closed); + + await send(client, { type: "hello", version: VERSION + 1 }); + + expect(await receive(client)).deep.equals({ + type: "hello_response", + version: VERSION, + error: "unsupported_version", + message: `Server supports protocol version ${VERSION}, client sent version ${VERSION + 1}`, + }); + + await closed; + expect(connection.connected).false; + + await connection.close(); + }); + + it("rejects a command sent before the handshake completes", async () => { + const { server } = MockWsConnection(); + const connection = new BleProxyConnection(server); + + errorOfType(await settlement(connection.sendCommand(BleProxyCommand.StopScan)), WsProxyConnectionClosedError); + + await connection.close(); + }); + + it("exposes a non-empty connection id with the ble prefix", async () => { + const { connection } = await connect(); + + expect(connection.id).match(/^ble[0-9a-f]+$/); + + await connection.close(); + }); + + it("sends a typed command and resolves with the typed result", async () => { + const { client, connection } = await connect(); + + const result = connection.sendCommand(BleProxyCommand.Connect, { address: "AA:BB:CC:DD:EE:FF" }); + + expect(await receive(client)).deep.equals({ + id: 0, + command: BleProxyCommand.Connect, + args: { address: "AA:BB:CC:DD:EE:FF" }, + }); + await send(client, { id: 0, success: true, result: { connection_handle: 1, mtu: 247 } }); + + expect(await result).deep.equals({ connection_handle: 1, mtu: 247 }); + + await connection.close(); + }); + + it("omits args for a command that takes none", async () => { + const { client, connection } = await connect(); + + const result = connection.sendCommand(BleProxyCommand.StopScan); + + expect(await receive(client)).deep.equals({ id: 0, command: BleProxyCommand.StopScan }); + await send(client, { id: 0, success: true }); + await result; + + await connection.close(); + }); + + it("rejects when the client returns an error response, preserving the wire message format", async () => { + const { client, connection } = await connect(); + + const result = settlement(connection.sendCommand(BleProxyCommand.Connect, { address: "XX" })); + + expect(await receive(client)).deep.equals({ + id: 0, + command: BleProxyCommand.Connect, + args: { address: "XX" }, + }); + await send(client, { id: 0, success: false, error: "device_not_found", message: "Device not found" }); + + const error = errorOfType(await result, WsProxyCommandError); + expect(error.code).equals("device_not_found"); + expect(error.message).equals("device_not_found: Device not found"); + + await connection.close(); + }); + + it("emits eventReceived for JSON events from the client", async () => { + const { client, connection } = await connect(); + + const received = new Promise<[event: string, data: Record]>(resolve => + connection.eventReceived.on((event, data) => resolve([event, data])), + ); + + await send(client, { event: "scan_stopped", data: { reason: "test" } }); + + const [event, data] = await received; + expect(event).equals("scan_stopped"); + expect(data.reason).equals("test"); + + await connection.close(); + }); + + it("emits binaryFrameReceived for binary frames from the client", async () => { + const { client, connection } = await connect(); + + const received = new Promise<{ opcode: number; handle: number; payload: Uint8Array }>(resolve => + connection.binaryFrameReceived.on(frame => resolve(frame)), + ); + + const writer = client.writable.getWriter(); + try { + const frame = new Uint8Array([BinaryFrameOpcode.Notification, 0x00, 0x05, 1, 2, 3]); + await writer.write(frame); + } finally { + writer.releaseLock(); + } + + const got = await received; + expect(got.opcode).equals(BinaryFrameOpcode.Notification); + expect(got.handle).equals(5); + expect(Array.from(got.payload)).deep.equals([1, 2, 3]); + + await connection.close(); + }); + + it("sends binary frames to the client", async () => { + const { client, connection } = await connect(); + + connection.sendBinaryFrame(BinaryFrameOpcode.WriteData, 42, new Uint8Array([0xaa, 0xbb])); + + const reader = client.readable.getReader(); + let frame: Uint8Array; + try { + const { value } = await reader.read(); + if (value === undefined || typeof value === "string") { + throw new NetworkError("Expected a binary frame"); + } + frame = Bytes.of(value); + } finally { + reader.releaseLock(); + } + + expect(frame[0]).equals(BinaryFrameOpcode.WriteData); + expect(frame[1]).equals(0x00); + expect(frame[2]).equals(42); + expect(Array.from(frame.subarray(3))).deep.equals([0xaa, 0xbb]); + + await connection.close(); + }); + + it("emits closed exactly once and rejects pending commands when the transport closes", async () => { + const { client, connection } = await connect(); + + let closeCount = 0; + connection.closed.on(() => { + closeCount++; + }); + + const pending = settlement(connection.sendCommand(BleProxyCommand.StopScan)); + expect(await receive(client)).deep.equals({ id: 0, command: BleProxyCommand.StopScan }); + + await client.writable.close(); + + errorOfType(await pending, WsProxyConnectionClosedError); + expect(connection.connected).false; + expect(closeCount).equals(1); + + await connection.close(); + expect(closeCount).equals(1); + }); + + it("emits closed exactly once across overlapping close() calls", async () => { + const { connection } = await connect(); + + let closeCount = 0; + connection.closed.on(() => { + closeCount++; + }); + + await Promise.all([connection.close(), connection.close()]); + + expect(closeCount).equals(1); + expect(connection.connected).false; + }); +}); diff --git a/packages/ws-ble/test/BleProxyIntegrationTest.ts b/packages/ws-ble/test/BleProxyIntegrationTest.ts new file mode 100644 index 0000000000..aae51e1cec --- /dev/null +++ b/packages/ws-ble/test/BleProxyIntegrationTest.ts @@ -0,0 +1,473 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Integration tests for the BLE proxy pipeline: the consumer stack (`ProxyBle` and friends) driving + * `BleProxyHandler` against a `BleProxyTestClient` speaking the wire protocol over a mock transport. + */ + +import { Millis, MockWsConnection, Seconds, Time, type Observable } from "@matter/general"; +import type { BleProxyConnection } from "../src/BleProxyConnection.js"; +import { BleProxyHandler } from "../src/BleProxyHandler.js"; +import { BinaryFrameOpcode, BleProxyCommand } from "../src/BleProxyProtocol.js"; +import { ProxyBle } from "../src/ProxyBle.js"; +import type { ProxyBleCentralInterface, ProxyBleChannel } from "../src/ProxyBleChannel.js"; +import { ProxyBleClient } from "../src/ProxyBleClient.js"; +import { BleProxyTestClient } from "./support/BleProxyTestClient.js"; +import { MockBleDevice } from "./support/MockBleDevice.js"; + +/** Discovery loops in `BleScanner` always run for the full timeout, so keep it short. */ +const DISCOVERY_TIMEOUT = Millis(500); + +/** Separates the BTP handshake indication from the WriteAndSubscribe response, the non-racing ordering. */ +const INDICATION_DELAY = Millis(30); + +function nextEmit(observable: Observable): Promise { + return new Promise(resolve => observable.once(() => resolve())); +} + +describe("BLE Proxy Integration", function () { + this.timeout(10_000); + + let handler: BleProxyHandler; + let connection: BleProxyConnection; + let testClient: BleProxyTestClient; + const pendingSends = new Array>(); + + beforeEach(async () => { + handler = new BleProxyHandler(); + const pair = MockWsConnection(); + connection = handler.accept(pair.server); + testClient = new BleProxyTestClient(); + await testClient.connect(pair.client); + }); + + afterEach(async () => { + await Promise.all(pendingSends); + pendingSends.length = 0; + await testClient.close(); + await handler.close(); + }); + + describe("handshake", () => { + it("should complete handshake and report connected", () => { + expect(handler.connected).to.be.true; + }); + }); + + describe("scanning", () => { + it("should send start_scan and stop_scan commands", async () => { + const proxyBle = new ProxyBle(handler); + const mockDevice = new MockBleDevice({ discriminator: 3840, vendorId: 0xfff1, productId: 0x8000 }); + + testClient.onCommand(BleProxyCommand.StartScan, async () => { + await testClient.sendEvent("device_discovered", mockDevice.discoveredEventData); + }); + + const devices = await proxyBle.scanner.findCommissionableDevicesContinuously( + {}, + () => {}, + DISCOVERY_TIMEOUT, + ); + + expect(devices.length).to.be.greaterThanOrEqual(1); + expect(devices[0].deviceIdentifier).to.equal(mockDevice.address); + + const commandNames = testClient.receivedCommands.map(c => c.command); + expect(commandNames).to.include("start_scan"); + expect(commandNames).to.include("stop_scan"); + }); + + it("should match by discriminator", async () => { + const proxyBle = new ProxyBle(handler); + const mockDevice = new MockBleDevice({ discriminator: 1234, vendorId: 0xfff1, productId: 0x8000 }); + + testClient.onCommand(BleProxyCommand.StartScan, async () => { + await testClient.sendEvent("device_discovered", mockDevice.discoveredEventData); + }); + + const devices = await proxyBle.scanner.findCommissionableDevicesContinuously( + { longDiscriminator: 1234 }, + () => {}, + DISCOVERY_TIMEOUT, + ); + + expect(devices.length).to.equal(1); + expect(devices[0].D).to.equal(1234); + }); + + it("should return empty when no matching device found", async () => { + const proxyBle = new ProxyBle(handler); + const mockDevice = new MockBleDevice({ discriminator: 5678, vendorId: 0xfff1, productId: 0x8000 }); + + testClient.onCommand(BleProxyCommand.StartScan, async () => { + await testClient.sendEvent("device_discovered", mockDevice.discoveredEventData); + }); + + const devices = await proxyBle.scanner.findCommissionableDevicesContinuously( + { longDiscriminator: 9999 }, + () => {}, + DISCOVERY_TIMEOUT, + ); + + expect(devices.length).to.equal(0); + }); + + it("stopScanning clears hub scan intent even after a transient scanStopped", async () => { + const client = new ProxyBleClient(handler); + try { + await client.startScanning(); + await testClient.waitForCommand("start_scan"); + + // A transient all-clients-stopped resets the client's scan flag via the hub's scanStopped + const scanStopped = nextEmit(handler.scanStopped); + await testClient.sendEvent("scan_stopped", { reason: "transient" }); + await scanStopped; + + // stopScanning must still reach the hub, otherwise its scan intent lingers + const stopPromise = testClient.waitForCommand("stop_scan"); + await client.stopScanning(); + const cmd = await stopPromise; + expect(cmd.command).to.equal("stop_scan"); + } finally { + client.close(); + } + }); + + it("re-broadcasts start_scan after a scanStopped that lands inside the start_scan round-trip", async () => { + const client = new ProxyBleClient(handler); + try { + // The stop lands while startScanning is still awaiting its own broadcast, the window in which a + // scan flag set after the await would overwrite it + testClient.onCommand(BleProxyCommand.StartScan, async () => { + await testClient.sendEvent("scan_stopped", { reason: "radio busy" }); + }); + + const scanStopped = nextEmit(handler.scanStopped); + await client.startScanning(); + await testClient.waitForCommand("start_scan"); + await scanStopped; + + const secondScan = testClient.waitForCommand("start_scan", Seconds(3)); + await client.startScanning(); + const cmd = await secondScan; + expect(cmd.command).to.equal("start_scan"); + } finally { + client.close(); + } + }); + + it("keeps a peripheral whose service data carries one undecodable entry", async () => { + const proxyBle = new ProxyBle(handler); + const mockDevice = new MockBleDevice({ discriminator: 2468, vendorId: 0xfff1, productId: 0x8000 }); + + testClient.onCommand(BleProxyCommand.StartScan, async () => { + const discovered = mockDevice.discoveredEventData as { service_data: Record }; + await testClient.sendEvent("device_discovered", { + ...discovered, + service_data: { "180a": "!not base64!", ...discovered.service_data }, + }); + }); + + const devices = await proxyBle.scanner.findCommissionableDevicesContinuously( + { longDiscriminator: 2468 }, + () => {}, + DISCOVERY_TIMEOUT, + ); + + expect(devices.length).to.equal(1); + expect(devices[0].deviceIdentifier).to.equal(mockDevice.address); + }); + }); + + describe("openChannel BTP handshake", () => { + const C1_UUID = "18EE2EF5-263D-4559-959F-4F9C429F9D11"; + const C2_UUID = "18EE2EF5-263D-4559-959F-4F9C429F9D12"; + + /** + * Wire up the command handlers for the full openChannel flow, with the BTP handshake response frame sent + * after the WriteAndSubscribe response. + */ + const wireBtpFlow = (mockDevice: MockBleDevice, connectionHandle = 1, mtu = 247) => { + testClient.onCommand(BleProxyCommand.Connect, async () => ({ + connection_handle: connectionHandle, + mtu, + })); + testClient.onCommand(BleProxyCommand.DiscoverServices, async () => ({ + services: mockDevice.services, + })); + testClient.onCommand(BleProxyCommand.DiscoverCharacteristics, async () => ({ + characteristics: mockDevice.characteristics, + })); + testClient.onCommand(BleProxyCommand.WriteAndSubscribe, async () => { + pendingSends.push( + Time.sleep("btp handshake indication", INDICATION_DELAY).then(() => + testClient.sendBinaryFrame( + BinaryFrameOpcode.Notification, + connectionHandle, + mockDevice.generateBtpHandshakeResponse(), + ), + ), + ); + return {}; + }); + }; + + /** Discover the mock device on the proxy scanner so openChannel can resolve it. */ + const discoverDevice = async (proxyBle: ProxyBle, mockDevice: MockBleDevice): Promise => { + testClient.onCommand(BleProxyCommand.StartScan, async () => { + await testClient.sendEvent("device_discovered", mockDevice.discoveredEventData); + }); + await proxyBle.scanner.findCommissionableDevicesContinuously({}, () => {}, DISCOVERY_TIMEOUT); + }; + + it("should complete BTP handshake and return a connected channel", async () => { + const proxyBle = new ProxyBle(handler); + const mockDevice = new MockBleDevice({ discriminator: 3840, vendorId: 0xfff1, productId: 0x8000 }); + + await discoverDevice(proxyBle, mockDevice); + wireBtpFlow(mockDevice); + + const central = proxyBle.centralInterface as ProxyBleCentralInterface; + // matter.js installs an onData listener before opening channels + central.onData(() => {}); + + const channel = (await central.openChannel({ + type: "ble", + peripheralAddress: mockDevice.address, + })) as ProxyBleChannel; + + expect(channel.connected).to.be.true; + expect(channel.name).to.equal(`ble-proxy://${mockDevice.address}`); + + const commandNames = testClient.receivedCommands.map(c => c.command); + expect(commandNames).to.include("connect"); + expect(commandNames).to.include("discover_services"); + expect(commandNames).to.include("discover_characteristics"); + expect(commandNames).to.include("write_and_subscribe"); + + const comboCmd = testClient.receivedCommands.find(c => c.command === "write_and_subscribe"); + const comboArgs = comboCmd?.args as { write_uuid: string; subscribe_uuid: string } | undefined; + expect(comboArgs?.write_uuid).to.equal(C1_UUID); + expect(comboArgs?.subscribe_uuid).to.equal(C2_UUID); + + await channel.close(); + }); + + it("sends disconnect to the proxy client when the channel is closed", async () => { + const proxyBle = new ProxyBle(handler); + const mockDevice = new MockBleDevice({ discriminator: 6100, vendorId: 0xfff1, productId: 0x8000 }); + + await discoverDevice(proxyBle, mockDevice); + wireBtpFlow(mockDevice, 11); + testClient.onCommand(BleProxyCommand.Disconnect, async () => ({})); + + const central = proxyBle.centralInterface as ProxyBleCentralInterface; + central.onData(() => {}); + + const channel = (await central.openChannel({ + type: "ble", + peripheralAddress: mockDevice.address, + })) as ProxyBleChannel; + + const disconnectPromise = testClient.waitForCommand("disconnect", Seconds(3)); + await channel.close(); + + const disconnectCmd = await disconnectPromise; + expect((disconnectCmd.args as { connection_handle: number }).connection_handle).to.equal(11); + }); + + it("sends no disconnect when the peripheral reported the disconnect itself", async () => { + const proxyBle = new ProxyBle(handler); + const mockDevice = new MockBleDevice({ discriminator: 6200, vendorId: 0xfff1, productId: 0x8000 }); + + await discoverDevice(proxyBle, mockDevice); + wireBtpFlow(mockDevice, 12); + testClient.onCommand(BleProxyCommand.Disconnect, async () => ({})); + + const central = proxyBle.centralInterface as ProxyBleCentralInterface; + central.onData(() => {}); + + const channel = (await central.openChannel({ + type: "ble", + peripheralAddress: mockDevice.address, + })) as ProxyBleChannel; + + const channelClosed = nextEmit(channel.closed); + await testClient.sendEvent("disconnected", { connection_handle: 12, reason: "peripheral gone" }); + await channelClosed; + + expect(channel.connected).to.be.false; + expect(testClient.receivedCommands.find(c => c.command === "disconnect")).to.be.undefined; + }); + + it("matches the Matter service when the client reports the compact 128-bit UUID form", async () => { + const proxyBle = new ProxyBle(handler); + const mockDevice = new MockBleDevice({ + discriminator: 6300, + vendorId: 0xfff1, + productId: 0x8000, + serviceUuid: "0000fff600001000800000805f9b34fb", + }); + + await discoverDevice(proxyBle, mockDevice); + wireBtpFlow(mockDevice, 13); + + const central = proxyBle.centralInterface as ProxyBleCentralInterface; + central.onData(() => {}); + + const channel = (await central.openChannel({ + type: "ble", + peripheralAddress: mockDevice.address, + })) as ProxyBleChannel; + + expect(channel.connected).to.be.true; + await channel.close(); + }); + + it("should complete handshake when the indication arrives before the WriteAndSubscribe response", async () => { + const proxyBle = new ProxyBle(handler); + const mockDevice = new MockBleDevice({ discriminator: 4242, vendorId: 0xfff1, productId: 0x8000 }); + + await discoverDevice(proxyBle, mockDevice); + + testClient.onCommand(BleProxyCommand.Connect, async () => ({ connection_handle: 1, mtu: 247 })); + testClient.onCommand(BleProxyCommand.DiscoverServices, async () => ({ services: mockDevice.services })); + testClient.onCommand(BleProxyCommand.DiscoverCharacteristics, async () => ({ + characteristics: mockDevice.characteristics, + })); + // Emit the indication before returning the command result, so the binary frame reaches the hub ahead of + // the WriteAndSubscribe reply — the ordering that breaks commissioning if the observer is late + testClient.onCommand(BleProxyCommand.WriteAndSubscribe, async () => { + await testClient.sendBinaryFrame( + BinaryFrameOpcode.Notification, + 1, + mockDevice.generateBtpHandshakeResponse(), + ); + return {}; + }); + + const central = proxyBle.centralInterface as ProxyBleCentralInterface; + central.onData(() => {}); + + const channel = (await central.openChannel({ + type: "ble", + peripheralAddress: mockDevice.address, + })) as ProxyBleChannel; + + expect(channel.connected).to.be.true; + await channel.close(); + }); + + it("should reject when openChannel is called before onData listener is installed", async () => { + const proxyBle = new ProxyBle(handler); + const mockDevice = new MockBleDevice({ discriminator: 1234, vendorId: 0xfff1, productId: 0x8000 }); + + await discoverDevice(proxyBle, mockDevice); + + const central = proxyBle.centralInterface as ProxyBleCentralInterface; + try { + await central.openChannel({ type: "ble", peripheralAddress: mockDevice.address }); + expect.fail("Should have thrown"); + } catch (err) { + expect((err as Error).message).to.include("Network Interface"); + } + }); + + it("should disconnect and throw when device lacks the required Matter characteristics", async () => { + const proxyBle = new ProxyBle(handler); + const mockDevice = new MockBleDevice({ discriminator: 5555, vendorId: 0xfff1, productId: 0x8000 }); + + await discoverDevice(proxyBle, mockDevice); + + testClient.onCommand(BleProxyCommand.Connect, async () => ({ connection_handle: 7, mtu: 244 })); + testClient.onCommand(BleProxyCommand.DiscoverServices, async () => ({ services: mockDevice.services })); + // Only C3 — the required C1/C2 are missing + testClient.onCommand(BleProxyCommand.DiscoverCharacteristics, async () => ({ + characteristics: [{ uuid: "64630238-8772-45F2-B87D-748A83218F04", properties: ["read"] }], + })); + const disconnectPromise = testClient.waitForCommand("disconnect", Seconds(3)); + testClient.onCommand(BleProxyCommand.Disconnect, async () => ({})); + + const central = proxyBle.centralInterface as ProxyBleCentralInterface; + central.onData(() => {}); + + try { + await central.openChannel({ type: "ble", peripheralAddress: mockDevice.address }); + expect.fail("Should have thrown"); + } catch (err) { + expect((err as Error).message).to.include("missing required Matter characteristics"); + } + + const disconnectCmd = await disconnectPromise; + expect((disconnectCmd.args as { connection_handle: number }).connection_handle).to.equal(7); + }); + + it("rejects openChannel (not crash) when the handshake times out while the write is still pending", async () => { + const proxyBle = new ProxyBle(handler); + const mockDevice = new MockBleDevice({ discriminator: 8888, vendorId: 0xfff1, productId: 0x8000 }); + + await discoverDevice(proxyBle, mockDevice); + + testClient.onCommand(BleProxyCommand.Connect, async () => ({ connection_handle: 9, mtu: 247 })); + testClient.onCommand(BleProxyCommand.DiscoverServices, async () => ({ services: mockDevice.services })); + testClient.onCommand(BleProxyCommand.DiscoverCharacteristics, async () => ({ + characteristics: mockDevice.characteristics, + })); + // The write ack never returns and no handshake frame is sent, so the handshake timer fires while + // WriteAndSubscribe is still pending: openChannel must reject, not leave an unhandled rejection + testClient.onCommand(BleProxyCommand.WriteAndSubscribe, () => new Promise(() => {})); + testClient.onCommand(BleProxyCommand.Disconnect, async () => ({})); + + const central = proxyBle.centralInterface as ProxyBleCentralInterface; + central.onData(() => {}); + + // Enable MockTime only around the timed-out phase so the handshake timer fires without a real 15s wait + MockTime.enable(); + try { + const settled = central.openChannel({ type: "ble", peripheralAddress: mockDevice.address }).then( + () => ({ ok: true as const }), + (err: Error) => ({ ok: false as const, err }), + ); + await testClient.waitForCommand("write_and_subscribe", Seconds(5)); + await MockTime.advance(Seconds(16)); + + const outcome = await settled; + expect(outcome.ok, "openChannel should reject, not resolve or crash").to.be.false; + if (!outcome.ok) { + expect(outcome.err.message).to.include("BTP handshake response not received"); + } + } finally { + MockTime.disable(); + } + }); + + it("tears down the channel when the owning proxy client disconnects", async () => { + const proxyBle = new ProxyBle(handler); + const mockDevice = new MockBleDevice({ discriminator: 7000, vendorId: 0xfff1, productId: 0x8000 }); + + await discoverDevice(proxyBle, mockDevice); + wireBtpFlow(mockDevice); + + const central = proxyBle.centralInterface as ProxyBleCentralInterface; + central.onData(() => {}); + + const channel = (await central.openChannel({ + type: "ble", + peripheralAddress: mockDevice.address, + })) as ProxyBleChannel; + expect(channel.connected).to.be.true; + + const channelClosed = nextEmit(channel.closed); + const connectionClosed = nextEmit(connection.closed); + await testClient.close(); + await connectionClosed; + + expect(channel.connected).to.be.false; + await channelClosed; + }); + }); +}); diff --git a/packages/ws-ble/test/BleProxyProtocolTest.ts b/packages/ws-ble/test/BleProxyProtocolTest.ts new file mode 100644 index 0000000000..e05aa4f310 --- /dev/null +++ b/packages/ws-ble/test/BleProxyProtocolTest.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BinaryFrameOpcode, + BLE_PROXY_PROTOCOL_VERSION, + decodeBinaryFrame, + encodeBinaryFrame, +} from "../src/BleProxyProtocol.js"; + +describe("BleProxyProtocol", () => { + describe("protocol version", () => { + it("should be 1", () => { + expect(BLE_PROXY_PROTOCOL_VERSION).to.equal(1); + }); + }); + + describe("encodeBinaryFrame / decodeBinaryFrame", () => { + it("should encode and decode a WRITE_DATA frame", () => { + const payload = new Uint8Array([0x01, 0x02, 0x03, 0x04]); + const encoded = encodeBinaryFrame(BinaryFrameOpcode.WriteData, 1, payload); + + expect(encoded.length).to.equal(7); // 3 header + 4 payload + expect(encoded[0]).to.equal(0x01); // opcode + expect(encoded[1]).to.equal(0x00); // connection_handle high + expect(encoded[2]).to.equal(0x01); // connection_handle low + + const decoded = decodeBinaryFrame(encoded); + expect(decoded.opcode).to.equal(BinaryFrameOpcode.WriteData); + expect(decoded.handle).to.equal(1); + expect(decoded.payload).to.deep.equal(payload); + }); + + it("should encode and decode a NOTIFICATION frame", () => { + const payload = new Uint8Array([0x65, 0x6c, 0x04, 0xf4, 0x00, 0x06]); + const encoded = encodeBinaryFrame(BinaryFrameOpcode.Notification, 42, payload); + const decoded = decodeBinaryFrame(encoded); + + expect(decoded.opcode).to.equal(BinaryFrameOpcode.Notification); + expect(decoded.handle).to.equal(42); + expect(decoded.payload).to.deep.equal(payload); + }); + + it("should encode and decode a READ_RESPONSE frame", () => { + const payload = new Uint8Array([0xaa, 0xbb]); + const encoded = encodeBinaryFrame(BinaryFrameOpcode.ReadResponse, 100, payload); + const decoded = decodeBinaryFrame(encoded); + + expect(decoded.opcode).to.equal(BinaryFrameOpcode.ReadResponse); + expect(decoded.handle).to.equal(100); + expect(decoded.payload).to.deep.equal(payload); + }); + + it("should handle empty payload", () => { + const payload = new Uint8Array(0); + const encoded = encodeBinaryFrame(BinaryFrameOpcode.WriteData, 1, payload); + + expect(encoded.length).to.equal(3); // header only + + const decoded = decodeBinaryFrame(encoded); + expect(decoded.opcode).to.equal(BinaryFrameOpcode.WriteData); + expect(decoded.handle).to.equal(1); + expect(decoded.payload.length).to.equal(0); + }); + + it("should handle max connection handle (0xFFFF)", () => { + const payload = new Uint8Array([0x01]); + const encoded = encodeBinaryFrame(BinaryFrameOpcode.Notification, 0xffff, payload); + + expect(encoded[1]).to.equal(0xff); + expect(encoded[2]).to.equal(0xff); + + const decoded = decodeBinaryFrame(encoded); + expect(decoded.handle).to.equal(0xffff); + }); + + it("should handle connection handle 0", () => { + const payload = new Uint8Array([0x01]); + const encoded = encodeBinaryFrame(BinaryFrameOpcode.WriteData, 0, payload); + + expect(encoded[1]).to.equal(0x00); + expect(encoded[2]).to.equal(0x00); + + const decoded = decodeBinaryFrame(encoded); + expect(decoded.handle).to.equal(0); + }); + + it("should throw on frame too short", () => { + expect(() => decodeBinaryFrame(new Uint8Array(2))).to.throw("Binary frame too short"); + expect(() => decodeBinaryFrame(new Uint8Array(1))).to.throw("Binary frame too short"); + expect(() => decodeBinaryFrame(new Uint8Array(0))).to.throw("Binary frame too short"); + }); + + it("should handle large payload", () => { + const payload = new Uint8Array(1024); + payload.fill(0x42); + const encoded = encodeBinaryFrame(BinaryFrameOpcode.WriteData, 5, payload); + const decoded = decodeBinaryFrame(encoded); + + expect(decoded.payload.length).to.equal(1024); + expect(decoded.payload[0]).to.equal(0x42); + expect(decoded.payload[1023]).to.equal(0x42); + }); + + it("should preserve big-endian connection handle encoding", () => { + // Handle 0x0102 should encode as [0x01, 0x02] + const encoded = encodeBinaryFrame(BinaryFrameOpcode.WriteData, 0x0102, new Uint8Array(0)); + expect(encoded[1]).to.equal(0x01); + expect(encoded[2]).to.equal(0x02); + }); + }); +}); diff --git a/packages/ws-ble/test/MultiClientBleProxyTest.ts b/packages/ws-ble/test/MultiClientBleProxyTest.ts new file mode 100644 index 0000000000..46aee2a4c7 --- /dev/null +++ b/packages/ws-ble/test/MultiClientBleProxyTest.ts @@ -0,0 +1,282 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Bytes, ImplementationError, MockWsConnection, type Observable } from "@matter/general"; +import type { BleProxyConnection } from "../src/BleProxyConnection.js"; +import { BleProxyHandler } from "../src/BleProxyHandler.js"; +import { BleProxyCommand } from "../src/BleProxyProtocol.js"; +import { BleProxyTestClient } from "./support/BleProxyTestClient.js"; + +const matterServiceData = Bytes.toBase64(new Uint8Array(8)); + +function nextEmit(observable: Observable): Promise { + return new Promise(resolve => observable.on(() => resolve())); +} + +describe("Multi-client BLE Proxy", () => { + before(() => MockTime.enable()); + + let handler: BleProxyHandler; + const clients = new Array(); + const connectionOf = new Map(); + + const addClient = async (): Promise => { + const pair = MockWsConnection(); + const connection = handler.accept(pair.server); + const client = new BleProxyTestClient(); + await client.connect(pair.client); + clients.push(client); + connectionOf.set(client, connection); + return client; + }; + + beforeEach(() => { + handler = new BleProxyHandler(); + }); + + afterEach(async () => { + await Promise.all(clients.map(c => c.close())); + clients.length = 0; + connectionOf.clear(); + await handler.close(); + }); + + it("accepts more than one client and reports connected", async () => { + await addClient(); + await addClient(); + expect(handler.connected).to.be.true; + }); + + it("broadcasts start_scan to all connected clients", async () => { + const a = await addClient(); + const b = await addClient(); + + await handler.startScan({ service_uuids: ["fff6"], allow_duplicates: false }); + + const [cmdA, cmdB] = await Promise.all([a.waitForCommand("start_scan"), b.waitForCommand("start_scan")]); + expect(cmdA.command).to.equal("start_scan"); + expect(cmdB.command).to.equal("start_scan"); + }); + + it("sends start_scan to a client that joins mid-scan", async () => { + await addClient(); + await handler.startScan({ service_uuids: ["fff6"], allow_duplicates: false }); + + const late = await addClient(); + const cmd = await late.waitForCommand("start_scan"); + expect(cmd.command).to.equal("start_scan"); + }); + + it("stopScan reaches connected clients even after a transient scanStopped", async () => { + const a = await addClient(); + + await handler.startScan({ service_uuids: ["fff6"], allow_duplicates: false }); + await a.waitForCommand("start_scan"); + + // A transient all-clients-stopped must not prevent a later stopScan() from reaching clients: + // #scanning is empty at this point, but stopScan() sends unconditionally to all connected clients. + const scanStoppedEmitted = nextEmit(handler.scanStopped); + await a.sendEvent("scan_stopped", { reason: "transient" }); + await scanStoppedEmitted; + + const stopPromise = a.waitForCommand("stop_scan"); + await handler.stopScan(); + const cmd = await stopPromise; + expect(cmd.command).to.equal("stop_scan"); + }); + + it("emits scanStopped only once every scanning client has reported stopped", async () => { + const a = await addClient(); + const b = await addClient(); + + await handler.startScan({ service_uuids: ["fff6"], allow_duplicates: false }); + await Promise.all([a.waitForCommand("start_scan"), b.waitForCommand("start_scan")]); + + let stopped = 0; + handler.scanStopped.on(() => { + stopped++; + }); + + const aEventReceived = nextEmit(connectionOf.get(a)!.eventReceived); + await a.sendEvent("scan_stopped", { reason: "a done" }); + await aEventReceived; + expect(stopped).to.equal(0); + + const bEventReceived = nextEmit(connectionOf.get(b)!.eventReceived); + await b.sendEvent("scan_stopped", { reason: "b done" }); + await bEventReceived; + expect(stopped).to.equal(1); + }); + + it("returns the same promise from every concurrent close() call", async () => { + await addClient(); + + // A second call issued before the first settles must join the same teardown, not report done early. + const first = handler.close(); + const second = handler.close(); + expect(second).to.equal(first); + + await first; + }); + + it("rejects accept() once closed", async () => { + await handler.close(); + + const pair = MockWsConnection(); + expect(() => handler.accept(pair.server)).to.throw(ImplementationError); + }); + + it("closes the rejected connection's transport when accept() is called after close()", async () => { + await handler.close(); + + const pair = MockWsConnection(); + expect(() => handler.accept(pair.server)).to.throw(ImplementationError); + + // accept() closed pair.server.writable; the other end of that pipe must see end-of-stream. + const reader = pair.client.readable.getReader(); + try { + const { done } = await reader.read(); + expect(done).to.be.true; + } finally { + reader.releaseLock(); + } + }); + + it("keeps notifying other connectionEstablished listeners after one throws", async () => { + let secondCalled = false; + handler.connectionEstablished.on(() => { + throw new Error("listener boom"); + }); + handler.connectionEstablished.on(() => { + secondCalled = true; + }); + + await addClient(); + + expect(secondCalled).to.be.true; + }); + + it("still syncs start_scan to a joining client when a connectionEstablished listener throws", async () => { + handler.connectionEstablished.on(() => { + throw new Error("listener boom"); + }); + + await addClient(); + await handler.startScan({ service_uuids: ["fff6"], allow_duplicates: false }); + + const late = await addClient(); + const cmd = await late.waitForCommand("start_scan"); + expect(cmd.command).to.equal("start_scan"); + }); + + it("emits scanStopped when the last scanning client disconnects", async () => { + const a = await addClient(); + + let stopped = false; + handler.scanStopped.on(() => { + stopped = true; + }); + + await handler.startScan({ service_uuids: ["fff6"], allow_duplicates: false }); + + const scanStoppedEmitted = nextEmit(handler.scanStopped); + await a.close(); + await scanStoppedEmitted; + + expect(stopped).to.be.true; + }); + + it("assigns ownership to the first client that discovers a peripheral", async () => { + const a = await addClient(); + const b = await addClient(); + + const address = "AA:BB:CC:DD:EE:FF"; + const discovered = { + address, + connectable: true, + service_data: { fff6: matterServiceData }, + }; + + // Client A sees it first, then B. + const seenByA = nextEmit(handler.deviceDiscovered); + await a.sendEvent("device_discovered", discovered); + await seenByA; + + const seenByB = nextEmit(handler.deviceDiscovered); + await b.sendEvent("device_discovered", discovered); + await seenByB; + + const owner = handler.getOwner(address); + expect(owner).to.not.be.undefined; + + // Route a connect through the owner; only client A should receive it. + a.onCommand(BleProxyCommand.Connect, async () => ({ connection_handle: 1, mtu: 247 })); + b.onCommand(BleProxyCommand.Connect, async () => ({ connection_handle: 9, mtu: 247 })); + + const aGotConnect = a.waitForCommand("connect"); + await owner!.sendCommand(BleProxyCommand.Connect, { address }); + + const cmd = await aGotConnect; + expect(cmd.command).to.equal("connect"); + expect(b.receivedCommands.find(c => c.command === "connect")).to.be.undefined; + }); + + it("reassigns ownership to another seer when the owner disconnects", async () => { + const a = await addClient(); + const b = await addClient(); + + const address = "AA:BB:CC:DD:EE:11"; + const discovered = { + address, + connectable: true, + service_data: { fff6: matterServiceData }, + }; + + const seenByA = nextEmit(handler.deviceDiscovered); + await a.sendEvent("device_discovered", discovered); + await seenByA; + + const seenByB = nextEmit(handler.deviceDiscovered); + await b.sendEvent("device_discovered", discovered); + await seenByB; + + expect(handler.getOwner(address)).to.not.be.undefined; + + // Drop client A (the first-seen owner). Ownership should fall to B. + const aClosed = nextEmit(connectionOf.get(a)!.closed); + await a.close(); + await aClosed; + + const owner = handler.getOwner(address); + expect(owner).to.not.be.undefined; + + b.onCommand(BleProxyCommand.Connect, async () => ({ connection_handle: 2, mtu: 247 })); + const bGotConnect = b.waitForCommand("connect"); + await owner!.sendCommand(BleProxyCommand.Connect, { address }); + + const cmd = await bGotConnect; + expect(cmd.command).to.equal("connect"); + }); + + it("drops a peripheral when its last seer disconnects", async () => { + const a = await addClient(); + + const address = "AA:BB:CC:DD:EE:22"; + const seen = nextEmit(handler.deviceDiscovered); + await a.sendEvent("device_discovered", { + address, + connectable: true, + service_data: { fff6: matterServiceData }, + }); + await seen; + expect(handler.getOwner(address)).to.not.be.undefined; + + const aClosed = nextEmit(connectionOf.get(a)!.closed); + await a.close(); + await aClosed; + expect(handler.getOwner(address)).to.be.undefined; + }); +}); diff --git a/packages/ws-ble/test/WsSmokeTest.ts b/packages/ws-ble/test/WsSmokeTest.ts new file mode 100644 index 0000000000..33878c4438 --- /dev/null +++ b/packages/ws-ble/test/WsSmokeTest.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Smoke test that runs the BLE proxy handshake and one command round-trip over a real localhost WebSocket, pinning the + * stream adapter the mock transport in the other suites bypasses. + */ + +import { Environment, InternalError, Logger, WebSocketClient } from "@matter/general"; +import { BleProxyHandler } from "../src/BleProxyHandler.js"; +import { BleProxyTestClient } from "./support/BleProxyTestClient.js"; + +const logger = Logger.get("WsSmokeTest"); + +// Non-literal specifiers keep node-only modules out of the browser bundle the Web test target builds. Inlining them +// as literals breaks that build. +const NODE_HTTP = ["node", "http"].join(":"); +const MATTER_NODEJS_WS = ["@matter", "nodejs-ws"].join("/"); + +type NodeHttp = typeof import("node:http"); +type MatterNodeJsWs = typeof import("@matter/nodejs-ws"); + +function importModule(specifier: string): Promise { + return import(specifier) as Promise; +} + +describe("BLE proxy over a real WebSocket", function () { + this.timeout(10_000); + + before(function () { + if (typeof window !== "undefined") { + this.skip(); + } + }); + + it("completes the handshake and a start_scan round-trip", async () => { + const [http, nodejsWs] = await Promise.all([ + importModule(NODE_HTTP), + importModule(MATTER_NODEJS_WS), + ]); + + const handler = new BleProxyHandler(); + const adapter = nodejsWs.factory(); + const server = http.createServer(); + + server.on("upgrade", (req, socket, head) => { + adapter + .handle(req, socket, head) + .then(connection => handler.accept(connection)) + .catch(error => logger.error("WebSocket upgrade failed:", error)); + }); + + let client: BleProxyTestClient | undefined; + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + + const address = server.address(); + if (address === null || typeof address === "string") { + throw new InternalError(`Expected an inet address but got ${address}`); + } + + const connection = await Environment.default + .get(WebSocketClient) + .connect(`ws://127.0.0.1:${address.port}/ble`); + client = new BleProxyTestClient(); + await client.connect(connection); + + expect(handler.connected).to.be.true; + + await handler.startScan({ service_uuids: ["fff6"], allow_duplicates: false }); + + const command = await client.waitForCommand("start_scan"); + expect(command.command).to.equal("start_scan"); + } finally { + await client?.close(); + await handler.close(); + await adapter.close(); + await new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())); + }); + } + }); +}); diff --git a/packages/ws-ble/test/support/BleProxyTestClient.ts b/packages/ws-ble/test/support/BleProxyTestClient.ts new file mode 100644 index 0000000000..ca69d33ba8 --- /dev/null +++ b/packages/ws-ble/test/support/BleProxyTestClient.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Test double for a BLE proxy client (the HA side of the protocol). + * + * Speaks the raw wire protocol directly over a mock transport rather than going through the + * hub's own connection wrapper, so tests exercise the hub against the same bytes a real proxy + * client would send. + */ + +import { + createPromise, + Logger, + PromiseTimeoutError, + WsProxyConnectionClosedError, + Seconds, + withTimeout, + type Duration, + type HttpEndpoint, +} from "@matter/general"; +import { + BLE_PROXY_PROTOCOL_VERSION, + BleProxyCommand, + encodeBinaryFrame, + type BleProxyCommandName, + type CommandMessage, +} from "../../src/BleProxyProtocol.js"; + +const logger = Logger.get("BleProxyTestClient"); + +const BLE_COMMAND_NAMES = new Set(Object.values(BleProxyCommand)); + +type CommandHandler = (args: Record) => Promise | void>; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isCommandMessage(value: Record): value is CommandMessage { + return typeof value.id === "number" && typeof value.command === "string" && BLE_COMMAND_NAMES.has(value.command); +} + +export class BleProxyTestClient { + #writer?: WritableStreamDefaultWriter; + #reader?: ReadableStreamDefaultReader; + #running?: Promise; + #commandHandlers = new Map(); + #receivedCommands = new Array(); + #commandWaiters = new Array<{ command: BleProxyCommandName; resolve: (msg: CommandMessage) => void }>(); + + /** Perform the hello handshake over `connection` and start processing subsequent messages. */ + async connect(connection: HttpEndpoint.WsConnection): Promise { + const writer = connection.writable.getWriter(); + this.#writer = writer; + const reader = connection.readable.getReader(); + this.#reader = reader; + + await this.#write(JSON.stringify({ type: "hello", version: BLE_PROXY_PROTOCOL_VERSION })); + + const { done, value } = await reader.read(); + if (done || typeof value !== "string") { + throw new WsProxyConnectionClosedError("BLE proxy connection closed during handshake"); + } + + const response = JSON.parse(value) as Record; + if (response.type !== "hello_response" || response.error !== undefined) { + throw new WsProxyConnectionClosedError( + `Handshake failed: ${response.error !== undefined ? String(response.error) : `unexpected message type ${String(response.type)}`}`, + ); + } + + this.#running = this.#readLoop(reader); + } + + /** Register the response an inbound command receives; unregistered commands auto-succeed with `{}`. */ + onCommand(command: BleProxyCommandName, handler: CommandHandler): void { + this.#commandHandlers.set(command, handler); + } + + async sendEvent(event: string, data: Record): Promise { + await this.#write(JSON.stringify({ event, data })); + } + + async sendBinaryFrame(opcode: number, connectionHandle: number, payload: Uint8Array): Promise { + await this.#write(encodeBinaryFrame(opcode, connectionHandle, payload)); + } + + /** Wait for a command of the given name, resolving immediately if one already arrived. */ + waitForCommand(command: BleProxyCommandName, timeout: Duration = Seconds(5)): Promise { + const existing = this.#receivedCommands.find(c => c.command === command); + if (existing) { + this.#receivedCommands = this.#receivedCommands.filter(c => c !== existing); + return Promise.resolve(existing); + } + + const { promise, resolver } = createPromise(); + const waiter = { command, resolve: resolver }; + this.#commandWaiters.push(waiter); + return withTimeout(timeout, promise, () => { + const idx = this.#commandWaiters.indexOf(waiter); + if (idx !== -1) { + this.#commandWaiters.splice(idx, 1); + } + throw new PromiseTimeoutError(`Timeout waiting for command: ${command}`); + }); + } + + get receivedCommands(): CommandMessage[] { + return [...this.#receivedCommands]; + } + + async close(): Promise { + const writer = this.#writer; + this.#writer = undefined; + if (writer) { + await writer.close(); + } + + const reader = this.#reader; + this.#reader = undefined; + if (reader) { + await reader.cancel(); + } + + await this.#running; + } + + async #readLoop(reader: ReadableStreamDefaultReader): Promise { + while (true) { + const { done, value } = await reader.read(); + if (done) { + return; + } + if (typeof value !== "string") { + // The test client only speaks the JSON command/event envelope, matching what the ownership and + // scan-broadcast tests exercise. + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + logger.warn("Received invalid JSON:", error); + continue; + } + if (!isRecord(parsed) || !isCommandMessage(parsed)) { + continue; + } + + this.#receivedCommands.push(parsed); + this.#resolveCommandWaiters(parsed); + this.#dispatchCommand(parsed).catch(error => logger.warn("Command dispatch failed:", error)); + } + } + + async #dispatchCommand(msg: CommandMessage): Promise { + const handler = this.#commandHandlers.get(msg.command); + if (!handler) { + await this.#write(JSON.stringify({ id: msg.id, success: true, result: {} })); + return; + } + + try { + const result = await handler(msg.args ?? {}); + await this.#write(JSON.stringify({ id: msg.id, success: true, result: result ?? {} })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await this.#write(JSON.stringify({ id: msg.id, success: false, error: "test_error", message })); + } + } + + #resolveCommandWaiters(msg: CommandMessage): void { + const idx = this.#commandWaiters.findIndex(w => w.command === msg.command); + if (idx !== -1) { + const waiter = this.#commandWaiters.splice(idx, 1)[0]; + waiter.resolve(msg); + } + } + + async #write(message: HttpEndpoint.WsMessage): Promise { + const writer = this.#writer; + if (!writer) { + throw new WsProxyConnectionClosedError("BleProxyTestClient is not connected"); + } + await writer.write(message); + } +} diff --git a/packages/ws-ble/test/support/MockBleDevice.ts b/packages/ws-ble/test/support/MockBleDevice.ts new file mode 100644 index 0000000000..3d085dad5c --- /dev/null +++ b/packages/ws-ble/test/support/MockBleDevice.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Simulates a Matter-compatible BLE peripheral for testing. + * Generates advertisement data and GATT service/characteristic structures. + */ + +import { Bytes } from "@matter/general"; + +export interface MockBleDeviceConfig { + discriminator: number; + vendorId: number; + productId: number; + address?: string; + name?: string; + /** UUID form the simulated proxy client reports for the Matter service. */ + serviceUuid?: string; +} + +export class MockBleDevice { + readonly address: string; + readonly name: string; + readonly discriminator: number; + readonly vendorId: number; + readonly productId: number; + readonly serviceUuid: string; + + constructor(config: MockBleDeviceConfig) { + this.discriminator = config.discriminator; + this.vendorId = config.vendorId; + this.productId = config.productId; + this.address = config.address ?? "AA:BB:CC:DD:EE:FF"; + this.name = config.name ?? `MATTER-${config.discriminator}`; + this.serviceUuid = config.serviceUuid ?? "fff6"; + } + + /** + * Generate the 8-byte Matter BLE advertisement service data (fff6). + * Format: [opcode(1)] [discriminator(2)] [vendorId(2)] [productId(2)] [flags(1)] + */ + get advertisementServiceData(): Uint8Array { + const data = new Uint8Array(8); + // Opcode byte: version=0, additional data=0 + data[0] = 0x00; + // Discriminator (little-endian 12-bit in 2 bytes) + data[1] = this.discriminator & 0xff; + data[2] = (this.discriminator >> 8) & 0x0f; + // Vendor ID (little-endian) + data[3] = this.vendorId & 0xff; + data[4] = (this.vendorId >> 8) & 0xff; + // Product ID (little-endian) + data[5] = this.productId & 0xff; + data[6] = (this.productId >> 8) & 0xff; + // Flags + data[7] = 0x00; + return data; + } + + /** + * Generate a device_discovered event data object for this device. + */ + get discoveredEventData(): Record { + return { + address: this.address, + name: this.name, + rssi: -55, + connectable: true, + service_data: { + [this.serviceUuid]: Bytes.toBase64(this.advertisementServiceData), + }, + service_uuids: [this.serviceUuid], + }; + } + + /** + * Return mock GATT services for this device. + */ + get services(): Array<{ uuid: string }> { + return [{ uuid: this.serviceUuid }]; + } + + /** + * Return mock GATT characteristics for the Matter service. + */ + get characteristics(): Array<{ uuid: string; properties: string[] }> { + return [ + { uuid: "18EE2EF5-263D-4559-959F-4F9C429F9D11", properties: ["write"] }, + { uuid: "18EE2EF5-263D-4559-959F-4F9C429F9D12", properties: ["notify"] }, + { uuid: "18EE2EF5-263D-4559-959F-4F9C429F9D13", properties: ["read"] }, + ]; + } + + /** + * Generate a BTP handshake response for a given request. + * Returns a minimal valid 6-byte handshake response. + */ + generateBtpHandshakeResponse(): Uint8Array { + // BTP handshake response: [0x65, 0x6C, version, mtu(2 bytes LE), windowSize] + const response = new Uint8Array(6); + response[0] = 0x65; // BTP response opcode byte 1 + response[1] = 0x6c; // BTP response opcode byte 2 + response[2] = 0x04; // BTP version 4 + response[3] = 0xf4; // MTU low byte (244) + response[4] = 0x00; // MTU high byte + response[5] = 0x06; // Window size 6 + return response; + } +} diff --git a/packages/ws-ble/test/tsconfig.json b/packages/ws-ble/test/tsconfig.json new file mode 100644 index 0000000000..d3e032419b --- /dev/null +++ b/packages/ws-ble/test/tsconfig.json @@ -0,0 +1,35 @@ +// Managed by nacho-build. Nacho will update references automatically but otherwise preserves your edits. +// Use `nacho-build configure` to overwrite with defaults. +{ + "extends": "../../../tsc/tsconfig.test.json", + "compilerOptions": { + "types": [ + "mocha", + "node", + "@matter/testing" + ] + }, + "references": [ + { + "path": "../../general/src" + }, + { + "path": "../../node/src" + }, + { + "path": "../../nodejs-ws/src" + }, + { + "path": "../../nodejs/src" + }, + { + "path": "../../protocol/src" + }, + { + "path": "../../testing/src" + }, + { + "path": "../src" + } + ] +} diff --git a/packages/ws-ble/tsconfig.json b/packages/ws-ble/tsconfig.json new file mode 100644 index 0000000000..1a108f0bf4 --- /dev/null +++ b/packages/ws-ble/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { "composite": true }, + "files": [], + "references": [{ "path": "src" }, { "path": "test" }] +} diff --git a/tsconfig.json b/tsconfig.json index 59dc4cb713..32b72e7640 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -49,6 +49,9 @@ { "path": "packages/nodejs-ws" }, + { + "path": "packages/ws-ble" + }, { "path": "examples/control-onoff" },