Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/drivers/ot-rcp-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<void> {
const entries = new Set<bigint>(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<void> {
await this.setProperty(writePropertyStreamRaw(payload, this.#streamRawConfig));
}
Expand Down Expand Up @@ -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();
Expand Down
25 changes: 25 additions & 0 deletions src/zigbee-stack/mac-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}

/**
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand All @@ -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();
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions test/compliance/aps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions test/compliance/bdb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions test/compliance/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions test/compliance/mac.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions test/compliance/nwk-gp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions test/compliance/nwk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions test/compliance/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
79 changes: 65 additions & 14 deletions test/drivers/ot-rcp-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
writePropertyb,
writePropertyC,
writePropertyc,
writePropertyId,
writePropertyS,
} from "../../src/spinel/spinel.js";
import { SpinelStatus } from "../../src/spinel/statuses.js";
Expand Down Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand Down Expand Up @@ -312,6 +315,7 @@ describe("OT RCP Driver", () => {
frames.mac154PANId,
frames.macRxOnWhenIdleMode,
frames.macRawStreamEnabled,
frames.macSrcMatchEnabled,
frames.phyTxPowerGet,
frames.phyRSSIGet,
frames.phyRXSensitivityGet,
Expand Down Expand Up @@ -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<bigint>();
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");

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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({
Expand Down
1 change: 1 addition & 0 deletions test/zigbee-stack/aps-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions test/zigbee-stack/mac-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions test/zigbee-stack/nwk-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions test/zigbee-stack/zigbee-stack.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const setup = () => {
onAPSSendTransportKeyNWK: () => Promise.resolve(),
onMarkRouteSuccess: () => {},
onMarkRouteFailure: () => {},
onSrcMatchUpdate: async () => {},
};

macHandler = new MACHandler(context, macCallbacks, NO_ACK_CODE);
Expand Down