From ee2dda915d6d9973e4e83c5c9f80fc248087cf03 Mon Sep 17 00:00:00 2001 From: Dirk Tostmann Date: Mon, 8 Jun 2026 01:20:49 +0200 Subject: [PATCH] fix: manage RCP source-match table so association/indirect poll ACKs carry Frame Pending The driver never touched the RCP source-match (pending) table and relied on the RCP's default Frame Pending behaviour for the auto-ACK it emits to a device's data request poll. That default is not portable across RCP vendors. On the ESP32 OpenThread RCP this breaks joining: the stack reset the driver performs during `start` puts the radio into the enhanced auto-pending mode with an empty source-match table, so every poll ACK carries Frame Pending = 0. A joining device reads that as "no data pending" after its association data request, abandons the association before adopting the short address assigned in the association response, and never receives the transport key. The same empty table also suppresses Frame Pending for sleepy end devices with queued data. Manage the table explicitly instead. `updateSrcMatchTable` rebuilds the set of extended addresses with MAC-layer data pending -- devices awaiting an association response (`pendingAssociations`) plus devices with a non-empty indirect-transmission queue -- and writes it via MAC_SRC_MATCH_EXTENDED_ADDRESSES. The MAC handler signals membership changes through a new `onSrcMatchUpdate` callback when a pending association is added/removed and when an indirect queue becomes non-empty/empty. MAC_SRC_MATCH_ENABLED is set during `formNetwork`. The host cannot set Frame Pending per-poll because the hardware auto-ACK precedes host handling, so pre-loading the table is the only portable mechanism. --- src/drivers/ot-rcp-driver.ts | 41 +++++++++++++ src/zigbee-stack/mac-handler.ts | 25 ++++++++ test/compliance/aps.test.ts | 1 + test/compliance/bdb.test.ts | 1 + test/compliance/integration.test.ts | 1 + test/compliance/mac.test.ts | 1 + test/compliance/nwk-gp.test.ts | 1 + test/compliance/nwk.test.ts | 1 + test/compliance/security.test.ts | 1 + test/drivers/ot-rcp-driver.test.ts | 79 ++++++++++++++++++++----- test/zigbee-stack/aps-handler.test.ts | 1 + test/zigbee-stack/mac-handler.test.ts | 1 + test/zigbee-stack/nwk-handler.test.ts | 1 + test/zigbee-stack/zigbee-stack.bench.ts | 1 + 14 files changed, 142 insertions(+), 14 deletions(-) diff --git a/src/drivers/ot-rcp-driver.ts b/src/drivers/ot-rcp-driver.ts index 58eb226..2b9beac 100644 --- a/src/drivers/ot-rcp-driver.ts +++ b/src/drivers/ot-rcp-driver.ts @@ -125,6 +125,9 @@ export class OTRCPDriver { onMarkRouteFailure: (destination16) => { this.nwkHandler.markRouteFailure(destination16); }, + onSrcMatchUpdate: async () => { + await this.updateSrcMatchTable(); + }, }; this.macHandler = new MACHandler(this.context, macCallbacks, SpinelStatus.NO_ACK, emitMACFrames); @@ -354,6 +357,39 @@ export class OTRCPDriver { await this.sendCommand(SpinelCommandId.PROP_VALUE_SET, payload, true, timeout); } + /** + * Sync the RCP source-match (pending) table with the set of devices that currently have data + * pending at the MAC layer: devices awaiting an association response, plus devices with a + * non-empty indirect-transmission queue. While a device's extended address is in the table, the + * RCP hardware sets Frame Pending = 1 in its auto-ACK to that device's data request poll, keeping + * the device awake to receive the indirect frame. + * + * This must be managed explicitly: the hardware auto-ACK is emitted by the RCP before the poll is + * handed to the host, so the host cannot set Frame Pending per-poll, only by pre-loading the + * source-match table. Relying on the RCP's default pending behaviour is not portable -- e.g. the + * ESP32 OpenThread RCP enters an enhanced pending mode with an empty table after a stack reset, + * yielding Frame Pending = 0 for every poll, which breaks association and indirect delivery. + */ + public async updateSrcMatchTable(): Promise { + const entries = new Set(this.context.pendingAssociations.keys()); + + for (const [address64, txs] of this.context.indirectTransmissions) { + if (txs.length > 0) { + entries.add(address64); + } + } + + const [data, offset] = writePropertyId(SpinelPropertyId.MAC_SRC_MATCH_EXTENDED_ADDRESSES, entries.size * 8); + let o = offset; + + for (const address64 of entries) { + data.writeBigUInt64BE(address64, o); + o += 8; + } + + await this.setProperty(data); + } + public async sendStreamRaw(payload: Buffer): Promise { await this.setProperty(writePropertyStreamRaw(payload, this.#streamRawConfig)); } @@ -572,6 +608,11 @@ export class OTRCPDriver { await this.setProperty(writePropertyb(SpinelPropertyId.MAC_RX_ON_WHEN_IDLE_MODE, true)); await this.setProperty(writePropertyb(SpinelPropertyId.MAC_RAW_STREAM_ENABLED, true)); + // Manage the source-match table ourselves (see `updateSrcMatchTable`) instead of relying on the + // RCP's default pending behaviour. The table is filled per-device as data becomes pending; it + // starts empty after the reset performed during `start`. + await this.setProperty(writePropertyb(SpinelPropertyId.MAC_SRC_MATCH_ENABLED, true)); + const txPower = await this.getPHYTXPower(); const radioRSSI = await this.getPHYRSSI(); this.context.rssiMin = await this.getPHYRXSensitivity(); diff --git a/src/zigbee-stack/mac-handler.ts b/src/zigbee-stack/mac-handler.ts index dfafc6d..086e8a4 100644 --- a/src/zigbee-stack/mac-handler.ts +++ b/src/zigbee-stack/mac-handler.ts @@ -31,6 +31,13 @@ export interface MACHandlerCallbacks { onMarkRouteSuccess: (destination16: number) => void; /** Called to mark route as failed */ onMarkRouteFailure: (destination16: number) => void; + /** + * Called whenever the set of devices with MAC-layer data pending changes (a pending association + * is added/removed, or an indirect-transmission queue becomes non-empty/empty), so the driver can + * sync the RCP source-match table. This governs the Frame Pending bit of the RCP's hardware + * auto-ACK to a device's data request poll, which the host cannot set per-poll. + */ + onSrcMatchUpdate: () => Promise; } /** @@ -174,6 +181,11 @@ export class MACHandler { NS, ); + // queue just became non-empty: device now has data pending -> Frame Pending = 1 on its poll ACK + if (addrTXs.length === 1) { + await this.#callbacks.onSrcMatchUpdate(); + } + return; // done } } @@ -356,6 +368,10 @@ export class MACHandler { }, timestamp: Date.now(), }); + + // register the joiner as data-pending so its association-poll ACK carries Frame Pending = 1, + // keeping it awake for the indirect association response + await this.#callbacks.onSrcMatchUpdate(); } return offset; @@ -564,6 +580,10 @@ export class MACHandler { // always delete, ensures no stale this.#context.pendingAssociations.delete(address64); + + // association poll handled: drop the joiner from the pending set (its poll ACK already + // carried Frame Pending = 1; it now has its short address) + await this.#callbacks.onSrcMatchUpdate(); } else { const addrTXs = this.#context.indirectTransmissions.get(address64); @@ -579,6 +599,11 @@ export class MACHandler { tx = addrTXs.shift(); } while (tx !== undefined); + + // queue drained: no more data pending for this device -> Frame Pending = 0 on its next poll ACK + if (addrTXs.length === 0) { + await this.#callbacks.onSrcMatchUpdate(); + } } } } diff --git a/test/compliance/aps.test.ts b/test/compliance/aps.test.ts index 98554e1..1116341 100644 --- a/test/compliance/aps.test.ts +++ b/test/compliance/aps.test.ts @@ -103,6 +103,7 @@ describe("Zigbee 3.0 Application Support (APS) Layer Compliance", () => { onAPSSendTransportKeyNWK: vi.fn(), onMarkRouteSuccess: vi.fn(), onMarkRouteFailure: vi.fn(), + onSrcMatchUpdate: vi.fn(), }; mockNWKHandlerCallbacks = { onAPSSendTransportKeyNWK: vi.fn(), diff --git a/test/compliance/bdb.test.ts b/test/compliance/bdb.test.ts index f41d6f3..5331bb1 100644 --- a/test/compliance/bdb.test.ts +++ b/test/compliance/bdb.test.ts @@ -91,6 +91,7 @@ describe("Zigbee 3.0 Device Behavior Compliance", () => { onAPSSendTransportKeyNWK: vi.fn(), onMarkRouteSuccess: vi.fn(), onMarkRouteFailure: vi.fn(), + onSrcMatchUpdate: vi.fn(), }; mockNWKHandlerCallbacks = { onAPSSendTransportKeyNWK: vi.fn(), diff --git a/test/compliance/integration.test.ts b/test/compliance/integration.test.ts index aa67e20..9cd765c 100644 --- a/test/compliance/integration.test.ts +++ b/test/compliance/integration.test.ts @@ -117,6 +117,7 @@ describe("Integration and End-to-End Compliance", () => { onAPSSendTransportKeyNWK: vi.fn(), onMarkRouteSuccess: vi.fn(), onMarkRouteFailure: vi.fn(), + onSrcMatchUpdate: vi.fn(), }; mockNWKHandlerCallbacks = { onAPSSendTransportKeyNWK: vi.fn(), diff --git a/test/compliance/mac.test.ts b/test/compliance/mac.test.ts index d8fe9ca..dbebc6e 100644 --- a/test/compliance/mac.test.ts +++ b/test/compliance/mac.test.ts @@ -94,6 +94,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { onAPSSendTransportKeyNWK: vi.fn(), onMarkRouteSuccess: vi.fn(), onMarkRouteFailure: vi.fn(), + onSrcMatchUpdate: vi.fn(), }; mockNWKHandlerCallbacks = { onAPSSendTransportKeyNWK: vi.fn(), diff --git a/test/compliance/nwk-gp.test.ts b/test/compliance/nwk-gp.test.ts index 86bd6a4..618d7dd 100644 --- a/test/compliance/nwk-gp.test.ts +++ b/test/compliance/nwk-gp.test.ts @@ -82,6 +82,7 @@ describe("Zigbee 3.0 Green Power (NWK GP) Compliance", () => { onAPSSendTransportKeyNWK: vi.fn(), onMarkRouteSuccess: vi.fn(), onMarkRouteFailure: vi.fn(), + onSrcMatchUpdate: vi.fn(), }; mockNWKHandlerCallbacks = { onAPSSendTransportKeyNWK: vi.fn(), diff --git a/test/compliance/nwk.test.ts b/test/compliance/nwk.test.ts index 4cd7739..44786c5 100644 --- a/test/compliance/nwk.test.ts +++ b/test/compliance/nwk.test.ts @@ -99,6 +99,7 @@ describe("Zigbee 3.0 Network Layer (NWK) Compliance", () => { onAPSSendTransportKeyNWK: vi.fn(), onMarkRouteSuccess: vi.fn(), onMarkRouteFailure: vi.fn(), + onSrcMatchUpdate: vi.fn(), }; mockNWKHandlerCallbacks = { onAPSSendTransportKeyNWK: vi.fn(), diff --git a/test/compliance/security.test.ts b/test/compliance/security.test.ts index 194b3d9..d3e6b76 100644 --- a/test/compliance/security.test.ts +++ b/test/compliance/security.test.ts @@ -121,6 +121,7 @@ describe("Zigbee 3.0 Security Compliance", () => { onAPSSendTransportKeyNWK: vi.fn(), onMarkRouteSuccess: vi.fn(), onMarkRouteFailure: vi.fn(), + onSrcMatchUpdate: vi.fn(), }; mockNWKHandlerCallbacks = { onAPSSendTransportKeyNWK: vi.fn(), diff --git a/test/drivers/ot-rcp-driver.test.ts b/test/drivers/ot-rcp-driver.test.ts index c8e0baf..c72ec42 100644 --- a/test/drivers/ot-rcp-driver.test.ts +++ b/test/drivers/ot-rcp-driver.test.ts @@ -16,6 +16,7 @@ import { writePropertyb, writePropertyC, writePropertyc, + writePropertyId, writePropertyS, } from "../../src/spinel/spinel.js"; import { SpinelStatus } from "../../src/spinel/statuses.js"; @@ -165,10 +166,11 @@ const FORM_FRAMES_SILABS = { mac154PANId: "7e8c0636d98579727e", macRxOnWhenIdleMode: "7e8d060000e68c7e", macRawStreamEnabled: "7e8e06370108437e", - phyTxPowerGet: "7e8106257d3343647e", - phyRSSIGet: "7e820626983d517e", - phyRXSensitivityGet: "7e8306279c7a127e", - phyCCAThresholdGet: "7e840624b5f0d37e", + macSrcMatchEnabled: "7e81068326011e807e", + phyTxPowerGet: "7e8206257d338e417e", + phyRSSIGet: "7e83062698864d7e", + phyRXSensitivityGet: "7e8406279c5b457e", + phyCCAThresholdGet: "7e850624b54bcf7e", }; /** OPENTHREAD/1.4.0-Koenkk-2025.2.1; CC13XX_CC26XX; Feb 3 2025 21:00:02 */ const FORM_FRAMES_TI = { @@ -180,10 +182,11 @@ const FORM_FRAMES_TI = { mac154PANId: "7e8c0636d9c57d5d307e", macRxOnWhenIdleMode: "7e8d060000e68c7e", macRawStreamEnabled: "7e8e06370108437e", - phyTxPowerGet: "7e81062505f47d317e", - phyRSSIGet: "7e820626ef05567e", - phyRXSensitivityGet: "7e830627a6a38c7e", - phyCCAThresholdGet: "7e8406000297567e", + macSrcMatchEnabled: "7e81068326011e807e", + phyTxPowerGet: "7e8206250539347e", + phyRSSIGet: "7e830626efbe4a7e", + phyRXSensitivityGet: "7e840627a682db7e", + phyCCAThresholdGet: "7e850600022c4a7e", }; // /** SL-OPENTHREAD/2.5.2.0_GitHub-1fceb225b; EFR32; Mar 19 2025 13:45:44 */ // const STOP_FRAMES_SILABS = { @@ -312,6 +315,7 @@ describe("OT RCP Driver", () => { frames.mac154PANId, frames.macRxOnWhenIdleMode, frames.macRawStreamEnabled, + frames.macSrcMatchEnabled, frames.phyTxPowerGet, frames.phyRSSIGet, frames.phyRXSensitivityGet, @@ -464,6 +468,47 @@ describe("OT RCP Driver", () => { } }); + it("syncs the source-match table with pending associations and non-empty indirect queues", async () => { + const setPropertySpy = vi.spyOn(driver, "setProperty").mockResolvedValue(); + const pendingJoiner = 0x1122334455667788n; + const indirectChild = 0x99aabbccddeeff00n; + const idleChild = 0x0102030405060708n; + + driver.context.pendingAssociations.set(pendingJoiner, { sendResp: async () => {}, timestamp: Date.now() }); + // non-empty queue -> data pending -> included + driver.context.indirectTransmissions.set(indirectChild, [{ sendFrame: async () => true, timestamp: Date.now() }]); + // empty queue -> nothing pending -> excluded + driver.context.indirectTransmissions.set(idleChild, []); + + await driver.updateSrcMatchTable(); + + expect(setPropertySpy).toHaveBeenCalledTimes(1); + + const payload = setPropertySpy.mock.calls[0][0]; + const [, offset] = writePropertyId(SpinelPropertyId.MAC_SRC_MATCH_EXTENDED_ADDRESSES, 0); + // property id prefix matches + expect(payload.subarray(0, offset)).toStrictEqual(writePropertyId(SpinelPropertyId.MAC_SRC_MATCH_EXTENDED_ADDRESSES, 0)[0]); + + const got = new Set(); + for (let o = offset; o < payload.byteLength; o += 8) { + got.add(payload.readBigUInt64BE(o)); + } + + expect(got).toStrictEqual(new Set([pendingJoiner, indirectChild])); + }); + + it("clears the source-match table when nothing is pending", async () => { + const setPropertySpy = vi.spyOn(driver, "setProperty").mockResolvedValue(); + + await driver.updateSrcMatchTable(); + + expect(setPropertySpy).toHaveBeenCalledTimes(1); + // property id only, no addresses + const [emptySet, offset] = writePropertyId(SpinelPropertyId.MAC_SRC_MATCH_EXTENDED_ADDRESSES, 0); + expect(setPropertySpy.mock.calls[0][0]).toStrictEqual(emptySet); + expect(setPropertySpy.mock.calls[0][0].byteLength).toStrictEqual(offset); + }); + it("handles loading with given network params - first start", async () => { const saveStateSpy = vi.spyOn(driver.context, "saveState"); @@ -2117,13 +2162,19 @@ describe("OT RCP Driver", () => { driver.parser._transform(makeSpinelStreamRaw(1, NET2_ASSOC_REQ_FROM_DEVICE), "utf8", () => {}); await vi.advanceTimersByTimeAsync(10); + // SRC_MATCH add (pending association) => OK + driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 1), "utf8", () => {}); + await vi.advanceTimersByTimeAsync(10); driver.parser._transform(makeSpinelStreamRaw(1, NET2_DATA_RQ_FROM_DEVICE), "utf8", () => {}); await vi.advanceTimersByTimeAsync(10); // ASSOC_RSP => OK - driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 1), "utf8", () => {}); + driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 2), "utf8", () => {}); await vi.advanceTimersByTimeAsync(10); // TRANSPORT_KEY NWK => OK - driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 2), "utf8", () => {}); + driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 3), "utf8", () => {}); + await vi.advanceTimersByTimeAsync(10); + // SRC_MATCH remove (association poll handled) => OK + driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 4), "utf8", () => {}); await vi.advanceTimersByTimeAsync(10); expect(savePeriodicStateSpy).toHaveBeenCalledTimes(1); @@ -2151,10 +2202,10 @@ describe("OT RCP Driver", () => { driver.parser._transform(makeSpinelStreamRaw(1, NET2_NODE_DESC_REQ_FROM_DEVICE, Buffer.from([0xce, 0xff, 0x00, 0x00])), "utf8", () => {}); await vi.advanceTimersByTimeAsync(10); // node desc APS ACK => OK - driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 3), "utf8", () => {}); + driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 5), "utf8", () => {}); await vi.advanceTimersByTimeAsync(10); // node desc RESP => OK - driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 4), "utf8", () => {}); + driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 6), "utf8", () => {}); await vi.advanceTimersByTimeAsync(10); driver.parser._transform( @@ -2164,13 +2215,13 @@ describe("OT RCP Driver", () => { ); await vi.advanceTimersByTimeAsync(10); // TRANSPORT_KEY TC => OK - driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 5), "utf8", () => {}); + driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 7), "utf8", () => {}); await vi.advanceTimersByTimeAsync(10); driver.parser._transform(makeSpinelStreamRaw(1, NET2_VERIFY_KEY_TC_FROM_DEVICE, Buffer.from([0xd5, 0xff, 0x00, 0x00])), "utf8", () => {}); await vi.advanceTimersByTimeAsync(10); // CONFIRM_KEY => OK - driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 6), "utf8", () => {}); + driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup + 8), "utf8", () => {}); await vi.advanceTimersByTimeAsync(10); expect(driver.context.deviceTable.get(11871832136131022815n)).toStrictEqual({ diff --git a/test/zigbee-stack/aps-handler.test.ts b/test/zigbee-stack/aps-handler.test.ts index e6ece12..5b68e83 100644 --- a/test/zigbee-stack/aps-handler.test.ts +++ b/test/zigbee-stack/aps-handler.test.ts @@ -89,6 +89,7 @@ describe("APS Handler", () => { onAPSSendTransportKeyNWK: vi.fn().mockResolvedValue(undefined), onMarkRouteSuccess: vi.fn(), onMarkRouteFailure: vi.fn(), + onSrcMatchUpdate: vi.fn(), }; mockMACHandler = new MACHandler(mockContext, mockMACCallbacks, 99999); diff --git a/test/zigbee-stack/mac-handler.test.ts b/test/zigbee-stack/mac-handler.test.ts index 2cb6795..2fc7396 100644 --- a/test/zigbee-stack/mac-handler.test.ts +++ b/test/zigbee-stack/mac-handler.test.ts @@ -85,6 +85,7 @@ describe("MACHandler", () => { onAPSSendTransportKeyNWK: vi.fn().mockResolvedValue(undefined), onMarkRouteSuccess: vi.fn(), onMarkRouteFailure: vi.fn(), + onSrcMatchUpdate: vi.fn(), }; macHandler = new MACHandler(mockContext, mockCallbacks, NO_ACK_CODE); diff --git a/test/zigbee-stack/nwk-handler.test.ts b/test/zigbee-stack/nwk-handler.test.ts index f12d36f..9e64915 100644 --- a/test/zigbee-stack/nwk-handler.test.ts +++ b/test/zigbee-stack/nwk-handler.test.ts @@ -68,6 +68,7 @@ describe("NWK Handler", () => { onAPSSendTransportKeyNWK: vi.fn().mockResolvedValue(undefined), onMarkRouteSuccess: vi.fn(), onMarkRouteFailure: vi.fn(), + onSrcMatchUpdate: vi.fn(), }; mockMACHandler = new MACHandler(mockContext, mockMACCallbacks, 99999); diff --git a/test/zigbee-stack/zigbee-stack.bench.ts b/test/zigbee-stack/zigbee-stack.bench.ts index 3fd647a..30a7879 100644 --- a/test/zigbee-stack/zigbee-stack.bench.ts +++ b/test/zigbee-stack/zigbee-stack.bench.ts @@ -71,6 +71,7 @@ const setup = () => { onAPSSendTransportKeyNWK: () => Promise.resolve(), onMarkRouteSuccess: () => {}, onMarkRouteFailure: () => {}, + onSrcMatchUpdate: async () => {}, }; macHandler = new MACHandler(context, macCallbacks, NO_ACK_CODE);