Skip to content

feat: BLE-over-WebSocket proxy — @matter/ws-ble package + generic ws-proxy framing in @matter/general - #4227

Draft
Apollon77 wants to merge 27 commits into
mainfrom
feat/ws-ble
Draft

feat: BLE-over-WebSocket proxy — @matter/ws-ble package + generic ws-proxy framing in @matter/general#4227
Apollon77 wants to merge 27 commits into
mainfrom
feat/ws-ble

Conversation

@Apollon77

@Apollon77 Apollon77 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds BLE-over-WebSocket proxy support to matter.js, extracted from matterjs-server's ble-proxy package:

  • @matter/general net/ws-proxy/ — generic, protocol-agnostic WebSocket proxy framing: hello/hello_response version handshake, correlated JSON command/response/event envelopes on text frames, binary frames [1B opcode][2B handle BE][payload], and WsProxyConnection operating on HttpEndpoint.WsConnection stream pairs (dial-direction-agnostic: works for server-accepted upgrades and WebSocketClient-dialed connections, supports command-initiator and command-handler roles). This layer is shared groundwork for the planned WS network tunnel (browser support).
  • @matter/ws-ble (new package) — the BLE proxy protocol v1 on top of that framing:
    • BleProxyHandler hub: mountable via HttpEndpoint.ws upgrades (accept(wsConnection)), multi-client with per-peripheral ownership tracking
    • ProxyBle/ProxyBleScanner/ProxyBleClient/ProxyBleChannel: the matter.js Ble implementation that runs commissioning over the proxy (BTP via BtpSessionHandler.createAsCentral)
    • @matter/ws-ble/noble-client subpath: hardware-side reference client on @stoprocent/noble + matter-ble-proxy CLI bin

The wire protocol is byte-for-byte compatible with deployed matterjs-server BLE proxy v1 clients (verified field-by-field against both the matterjs-server TypeScript implementation and the Python client's protocol module; all extensions are additive/optional).

The endpoint is unauthenticated by design — securing it is the embedder's responsibility. This is documented prominently in the package README and the relevant class docs.

Fixes over the original implementation

Found during extraction reviews (all present in the deployed matterjs-server version):

  • ProxyBleChannel.close() cleared its connected flag before btpSession.close(), so the courtesy Disconnect was never sent on normal channel close and the proxy client kept the GATT link. Now ordered like NobleBleChannel, with the disconnect send bounded to 5 s so teardown cannot hang on an unresponsive proxy client.
  • The reference client now implements write_and_subscribe (atomic C1 write + C2 CCCD enable). The original JS example client never did, although the hub sends it for every BTP handshake — only the Python client worked.
  • Scan-state desync: a scan_stopped landing inside the start_scan round-trip could permanently strand the client-side scan flag; discovery then silently returned nothing.
  • Noble client hardening: scan resume after connect is now gated on hub scan intent (previously the macOS pause/resume workaround left the radio scanning forever after commissioning), GATT writes are serialized per connection (noble's onceExclusive("write") silently drops concurrent completion callbacks), malformed service_data entries no longer discard the whole peripheral, and compact service-UUID forms are canonicalized before matching.

Testing

  • 56 tests in @matter/ws-ble (ESM/CJS/Web): protocol golden vectors pinning the v1 wire format, hub multi-client/ownership lifecycle, full hub↔client integration including the BTP handshake indication-before-response race, and a real-WebSocket smoke test through @matter/nodejs-ws.
  • @matter/general gains framing-layer tests (codec vectors, handshake both roles, command correlation/timeouts, close/cleanup paths).
  • Noble GATT paths are hardware-gated (as upstream); the transport edge was validated live against a real hub over real sockets. A follow-up task covers an end-to-end commissioning-over-proxy simulator test.

matterjs-server migration to this package (and whether its ble-proxy package remains as a compatibility wrapper) is tracked separately; the Python client stays in matterjs-server.

🤖 Generated with Claude Code

Verification

Run at branch tip before opening the PR (repo root):

  • npm run build -- --clean — clean, zero errors
  • npm run format-verify — "All matched files use Prettier code style!"
  • npm run lint — zero warnings/errors
  • npm test — full monorepo suite, exit 0; @matter/ws-ble 56/56 and @matter/general 1326/1326, each on ESM, CJS, and Web targets
  • Wire-compat cross-check vs the deployed Python client's protocol.py: protocol version 1, opcodes 0x01/0x02/0x03, >BH frame header — identical

Apollon77 and others added 17 commits August 7, 2026 22:28
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generic WebSocket-proxy connection over an HttpEndpoint.WsConnection stream
pair: hello handshake in either role, correlated JSON commands, events,
binary frames and an inbound command handler.

Generalizes the responder-only, ws-socket-based BLE proxy connection from
matterjs-server, keeping the wire format compatible. Notable differences
from that implementation: a command that times out now rejects instead of
leaving its promise permanently pending, command IDs skip IDs still in
flight so a 16-bit wrap cannot orphan a pending command, and an inbound
command with no handler installed is answered rather than dropped.

ProxyCommandError gains `detail`, the wire message without the code prefix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ned() helper

A close that lands while a handshake response is in flight could be
overwritten: the handshake state was committed after an awaited write, so
the connection reported itself open after `closed` had already been emitted.
Commit the handshake through a guard that refuses once the terminal state is
reached, and ignore inbound traffic that arrives after it.

Add `opened()`, which resolves on handshake completion and rejects if the
connection closes first, so consumers no longer race `handshakeCompleted`
against `closed`. Observers are removed once it settles and no promise is
retained, so an uncalled `opened()` cannot leave a rejection unobserved.

`sendFrame` now reports a closed connection before validating opcode and
handle range.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eference

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aths

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lient

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…adapter readiness

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ty notes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-proxy exports

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces generic WebSocket proxy framing and a new BLE-over-WebSocket implementation for remote Matter commissioning.

Changes:

  • Adds reusable WebSocket handshake, command, event, and binary-frame infrastructure.
  • Adds @matter/ws-ble, including hub, BLE transport, Noble client, and CLI.
  • Adds integration tests, documentation, workspace configuration, and changelog entries.

Reviewed changes

Copilot reviewed 33 out of 35 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
tsconfig.json References the new package.
packages/ws-ble/tsconfig.json Configures package projects.
packages/ws-ble/test/WsSmokeTest.ts Tests a real WebSocket round trip.
packages/ws-ble/test/tsconfig.json Configures package tests.
packages/ws-ble/test/support/MockBleDevice.ts Provides a mock Matter BLE peripheral.
packages/ws-ble/test/support/BleProxyTestClient.ts Implements the proxy test client.
packages/ws-ble/test/MultiClientBleProxyTest.ts Tests multi-client ownership and scanning.
packages/ws-ble/test/BleProxyProtocolTest.ts Tests protocol framing vectors.
packages/ws-ble/test/BleProxyIntegrationTest.ts Tests the complete proxy BLE pipeline.
packages/ws-ble/test/BleProxyConnectionTest.ts Tests BLE proxy connection behavior.
packages/ws-ble/src/tsconfig.json Configures source compilation.
packages/ws-ble/src/ProxyBleScanner.ts Adapts the standard BLE scanner.
packages/ws-ble/src/ProxyBleClient.ts Handles proxy scanning and discoveries.
packages/ws-ble/src/ProxyBleChannel.ts Implements proxy BTP channels and transport.
packages/ws-ble/src/ProxyBle.ts Exposes the proxy Ble implementation.
packages/ws-ble/src/noble-client/NobleBleProxyClient.ts Implements the Noble hardware client.
packages/ws-ble/src/noble-client/index.ts Exports the Noble client.
packages/ws-ble/src/noble-client/cli.ts Adds the proxy CLI.
packages/ws-ble/src/index.ts Exports package APIs.
packages/ws-ble/src/BleProxyProtocol.ts Defines BLE proxy protocol types.
packages/ws-ble/src/BleProxyHandler.ts Implements the multi-client hub.
packages/ws-ble/src/BleProxyConnection.ts Wraps generic proxy connections.
packages/ws-ble/README.md Documents architecture, security, and usage.
packages/ws-ble/package.json Defines package exports and dependencies.
packages/ws-ble/LICENSE Adds the package license.
packages/general/test/net/ws-proxy/WsProxyFrameTest.ts Tests generic binary framing.
packages/general/test/net/ws-proxy/WsProxyConnectionTest.ts Tests generic connection lifecycle and messaging.
packages/general/src/net/ws-proxy/WsProxyMessage.ts Defines shared message envelopes and errors.
packages/general/src/net/ws-proxy/WsProxyFrame.ts Implements shared binary framing.
packages/general/src/net/ws-proxy/WsProxyConnection.ts Implements shared WebSocket proxy connections.
packages/general/src/net/ws-proxy/index.ts Exports proxy infrastructure.
packages/general/src/net/index.ts Re-exports proxy APIs.
package.json Registers the workspace package.
package-lock.json Locks the new workspace and dependencies.
CHANGELOG.md Records the new framing and BLE package.
Suppressed comments (2)

packages/ws-ble/src/ProxyBleChannel.ts:417

  • A disconnected channel currently reports a successful send() while dropping the Matter message. Callers then wait for protocol timeouts instead of learning that the transport is gone; the native BLE channel throws on this path. Throw a typed BLE error here.
    async send(data: Bytes) {
        if (!this.#connected) {
            logger.debug(`Cannot send data - not connected to ${this.#peripheralAddress}`);
            return;
        }

packages/ws-ble/src/ProxyBleChannel.ts:432

  • This teardown is not idempotent. Overlapping explicit closes—or the disconnected event triggered by the courtesy Disconnect while the first close is still awaiting its response—run observer cleanup and every onClose listener multiple times. Cache and return one close promise (as BleProxyHandler does) so all callers join a single teardown.
    async close() {
        this.#cleanupObservers();
        this.#terminateIterator();
        for (const listener of this.#closeListeners) {
            listener();

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/ws-ble/src/ProxyBleScanner.ts
Comment thread packages/ws-ble/src/ProxyBleChannel.ts
Comment thread packages/ws-ble/src/noble-client/NobleBleProxyClient.ts
Comment thread packages/ws-ble/src/noble-client/NobleBleProxyClient.ts Outdated
Comment thread packages/ws-ble/src/noble-client/NobleBleProxyClient.ts Outdated
Comment thread package.json
Comment thread packages/ws-ble/src/noble-client/NobleBleProxyClient.ts
Comment thread packages/general/src/net/ws-proxy/WsProxyFrame.ts
…validation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Apollon77
Apollon77 requested a balanced review from Copilot August 12, 2026 22:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 33 out of 35 changed files in this pull request and generated no new comments.

Suppressed comments (5)

packages/ws-ble/src/ProxyBleScanner.ts:36

  • Closing the scanner only detaches observers; it never calls stopScanning(). If ProxyBle.close() runs during an active discovery (including runtime shutdown), the handler retains scan intent and connected hardware clients continue scanning. Preserve the base scanner's resource-release behavior by stopping first and detaching in finally.
    protected override closeClient(): void {
        this.#proxyClient.close();
    }

packages/ws-ble/src/BleProxyHandler.ts:164

  • A connectable: false report can become the permanent owner even though ProxyBleClient rejects that same report. If another client later reports the address as connectable, ownership remains with the first connected client and openChannel() routes connect to a client that cannot connect. Do not add non-connectable sightings to the ownership pool.
        let entry = this.#owners.get(data.address);

packages/ws-ble/src/noble-client/NobleBleProxyClient.ts:435

  • The disconnect path drops the connection state without removing the data listeners stored in subscriptions. Noble reuses peripheral/characteristic objects, so reconnecting and subscribing again can leave the old listener forwarding duplicate notifications under a stale handle. Remove all subscription listeners before discarding the state, as NobleBleChannel does on disconnect.
        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 });
        };

packages/ws-ble/package.json:79

  • The package publishes @matter/ws-ble/noble-client, but typesVersions only maps the root entry. TypeScript consumers using classic/legacy module resolution therefore cannot resolve declarations for the documented subpath. Add a mapping for the exported subpath, consistent with other multi-entry packages such as packages/react-native/package.json.
    "typesVersions": {
        "*": {
            ".": [
                "/dist/cjs/index.d.ts"
            ]
        }

packages/ws-ble/src/ProxyBleChannel.ts:440

  • Every repeated or overlapping close() call invokes all onClose listeners again, even though the inherited closed observable is latched to emit once. This can run consumer teardown multiple times. Clear the listener set after the first notification (or cache one close promise) so close remains idempotent.
        for (const listener of this.#closeListeners) {
            listener();
        }

…endency; order changelog entry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Apollon77 and others added 3 commits August 14, 2026 12:16
# Conflicts:
#	CHANGELOG.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants