fix(nodejs-ble): reach a peripheral that stops responding after the BTP handshake - #4269
fix(nodejs-ble): reach a peripheral that stops responding after the BTP handshake#4269Apollon77 wants to merge 7 commits into
Conversation
…TP handshake Some Bluetooth peripherals complete the BTP session handshake, acknowledge the first data write at the Bluetooth level, and then never act on it. Commissioning sat there for 15 seconds and gave up. It is reproducible with IKEA GRILLPLATS plugs on Bluetooth 4.1 adapters, and the same plug commissions fine from a Bluetooth 5 adapter. The cause is packet size. BTP carries a Matter message in segments, and the segment size is negotiated during the handshake from the Bluetooth connection's ATT_MTU - up to 244 bytes. On an adapter without Data Length Extension a single Bluetooth packet carries only 27 bytes, so a 244-byte segment is split across roughly five packets on the air. These peripherals reassemble the write at the Bluetooth level and answer it there, but their BTP layer never sees it. A segment small enough to fit one Bluetooth packet reaches them. When a session that we opened has received nothing at all from the peer since the handshake - no acknowledgement and no packet of its own - it now reports that instead of closing, and the transport establishes a fresh session with the smallest segment size the specification allows (20 bytes) and resends what the peer never acknowledged. The specification says a client closes a BTP session by unsubscribing from characteristic C2 and that this leaves the Bluetooth connection alone (Matter core specification section 4.19.3.3), so this costs no reconnection. It happens at most once per channel, and only for a session above the minimum segment size, so a peripheral that stays silent afterwards is given up on as before. This is an interop workaround, not specified behaviour: the specification neither describes nor forbids re-establishing a session on a live connection. Two related fixes: - The negotiated ATT_MTU can still be in flight when the connection interview finishes, and it arrives through an event rather than the initial value. We waited on neither, so a late value pinned the segment size to the 20-byte minimum for the whole session. The exchange is now given a short window to land. - A session no longer accepts a segment size larger than the one it offered. A peripheral is not allowed to select more than the client proposed, and without this bound one that echoes an oversized value would undo the reduction above. @matter/react-native shares the handshake code and gets the segment-size bound, but not the renegotiation or the ATT_MTU wait; unifying the two transports is filed separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds Node.js BLE recovery for peripherals that stall after BTP handshake.
Changes:
- Detects stalled BTP sessions and retries using minimum segment size.
- Waits briefly for late ATT_MTU negotiation and bounds peer-selected segment size.
- Adds comprehensive protocol and transport tests.
Verification commands are reported green in the PR description.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
CHANGELOG.md |
Documents BLE and protocol changes. |
packages/nodejs-ble/src/NobleBleChannel.ts |
Implements MTU waiting and session renegotiation. |
packages/nodejs-ble/test/NobleBleChannelTest.ts |
Tests recovery and failure paths. |
packages/protocol/src/ble/BtpSessionHandler.ts |
Adds stall detection and replay support. |
packages/protocol/src/codec/BtpCodec.ts |
Adds handshake-response detection. |
packages/protocol/test/ble/BtpSessionHandlerTest.ts |
Tests stall and segment-size behavior. |
packages/react-native/src/ble/ReactNativeBleChannel.ts |
Supplies the requested segment-size bound. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…eral abandons Two problems with the session renegotiation, both found in review. A transport that does not observe the new stall report was left worse off than before. The session suspended itself and reported the stall regardless of whether anyone was listening, so for @matter/react-native - which observes only the close - an unresponsive peer left the channel connected with a dormant session instead of closing after the acknowledgement timeout. The stall report is now made only when someone observes it; a session nobody observes closes exactly as it always did. A physical disconnect did not end a renegotiation that was waiting for its handshake response. Only an explicit close cancelled the wait, so a peripheral that vanished mid renegotiation left the handshake timer armed, and any send waiting on the renegotiation parked, until that timer expired 15 seconds later. The disconnect path now cancels the same way a close does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (4)
packages/protocol/src/codec/BtpCodec.ts:76
- This new public codec API is missing the required Matter specification reference in its JSDoc. Please link the handshake-response section so generated API docs retain the protocol contract.
static isHandshakeResponse(data: Bytes) {
packages/protocol/src/ble/BtpSessionHandler.ts:524
- Because
suspend()is a new exported public API, its JSDoc needs the required Matter specification reference.
suspend() {
packages/protocol/src/ble/BtpSessionHandler.ts:69
- This new public observable also needs the repository-required
@seereference to the relevant BTP session section for generated API documentation.
get stalledAfterHandshake() {
packages/protocol/src/ble/BtpSessionHandler.ts:151
- The updated public factory JSDoc documents the new bound but omits the required reference to the handshake negotiation rules it implements.
static async createAsCentral(
|
Tick the box to add this pull request to the merge queue (same as
|
What breaks today
Some Bluetooth peripherals complete the BTP session handshake, acknowledge the first data write at the Bluetooth level, and then never act on it. Commissioning waits 15 seconds and gives up. Reported by three users against IKEA GRILLPLATS plugs; the same plug commissions fine from a different Bluetooth adapter.
Why
BTP carries a Matter message in segments, and the segment size is negotiated during the handshake from the Bluetooth connection's ATT_MTU — up to 244 bytes. On a Bluetooth adapter without Data Length Extension (introduced in Bluetooth 4.2) a single over-the-air packet carries 27 bytes, so a 244-byte segment is split across roughly five packets. These peripherals reassemble the write at the Bluetooth level and answer it there, but their BTP layer never sees it.
Two of the affected users are on Bluetooth 4.1 adapters; the one on Bluetooth 5 succeeds. A user confirmed the mechanism directly by patching the built
NobleBleChannel.jsto force the minimum segment size — commissioning then succeeded on the first attempt on hardware that had never worked.The fix
When a session we opened has received nothing at all from the peer since the handshake — no acknowledgement and no packet of its own —
BtpSessionHandlernow reports that through a newstalledAfterHandshakeobservable instead of closing, handing over the Matter messages the peer never acknowledged. The Node.js BLE transport then establishes a fresh session at the smallest segment size the specification allows (20 bytes) and resends them.No reconnection is needed. Matter core specification §4.19.3.3: "To close a BTP session, a GATT client SHALL unsubscribe from characteristic C2", and §4.19.3.2: "A BTP session MAY open and close with no effect on the state of the underlying Bluetooth LE connection". So the recovery is unsubscribe, re-handshake, resubscribe — roughly half a second, well inside the 30 s the Matter layer allows for a response.
This is an interop workaround, not specified behaviour. The specification neither describes nor forbids re-establishing a session on a live connection; CHIP drops the connection and reconnects instead. It runs at most once per channel and only for a session above the minimum segment size, so a peripheral that stays silent afterwards is given up on exactly as before.
Two related fixes
Deliberately not in this PR
@matter/react-nativeduplicates the handshake and gets the segment-size bound but not the renegotiation or the MTU wait. Unifying both transports behind a shared GATT-client port in@matter/protocolis filed as a follow-up; it is a refactor of two platform packages and does not belong in a bugfix.MINIMUM_ATT_MTU. It holds 20, a segment size, while the minimum ATT_MTU is 23. Left alone deliberately.Testing
npm run build-clean,npm run format-verify,npm run lintand the fullnpm testare green.Twelve new tests across
BtpSessionHandlerTestandNobleBleChannelTest. Every production hunk was mutation-tested — reverted individually, with the test confirmed to fail — including:Not yet validated against real hardware. The fix is proven against a peripheral simulator; the bug it addresses is an interop bug, which is the class self-consistent tests do not settle. Live validation against an actual GRILLPLATS on a Bluetooth 4.1 adapter should happen before release.
🤖 Generated with Claude Code