zigbee-on-host
Version:
Zigbee stack designed to run on a host and communicate with a radio co-processor (RCP)
1,030 lines • 108 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.APSHandler = exports.CONFIG_APS_MAX_FRAME_RETRIES = exports.CONFIG_APS_ACK_WAIT_DURATION_MS = void 0;
const logger_js_1 = require("../utils/logger.js");
const mac_js_1 = require("../zigbee/mac.js");
const zigbee_aps_js_1 = require("../zigbee/zigbee-aps.js");
const zigbee_nwk_js_1 = require("../zigbee/zigbee-nwk.js");
const nwk_handler_js_1 = require("./nwk-handler.js");
const stack_context_js_1 = require("./stack-context.js");
const NS = "aps-handler";
/** apsDuplicateEntryLifetime: Duration while APS duplicate table entries remain valid (milliseconds). Spec default ≈ 8s. */
const CONFIG_APS_DUPLICATE_TIMEOUT_MS = 8000; // TODO: verify
/** apsAckWaitDuration: Default ack wait duration per Zigbee 3.0 spec (milliseconds). */
exports.CONFIG_APS_ACK_WAIT_DURATION_MS = 1600 + 500; // some extra for ZoH
/** apsMaxFrameRetries: Default number of APS retransmissions when ACK is missing. */
exports.CONFIG_APS_MAX_FRAME_RETRIES = 3;
/** apsFragmentationTimeout: Timeout for incomplete incoming APS fragment reassembly (milliseconds). */
const CONFIG_APS_FRAGMENT_REASSEMBLY_TIMEOUT_MS = 30000; // TODO: verify
/**
* APS Handler - Zigbee Application Support Layer Operations
*/
class APSHandler {
#context;
#macHandler;
#nwkHandler;
#callbacks;
// Private counters (start at 0, first call returns 1)
#counter = 0;
#zdoSeqNum = 0;
/** Recently seen frames for duplicate rejection by NWK 16 */
#duplicateTable16 = new Map();
/** Recently seen frames for duplicate rejection by NWK 64 */
#duplicateTable64 = new Map();
/** Pending acknowledgments waiting for retransmission */
#pendingAcks = new Map();
/** Incoming fragment reassembly buffers */
#incomingFragments = new Map();
constructor(context, macHandler, nwkHandler, callbacks) {
this.#context = context;
this.#macHandler = macHandler;
this.#nwkHandler = nwkHandler;
this.#callbacks = callbacks;
}
async start() { }
stop() {
for (const entry of this.#pendingAcks.values()) {
if (entry.timer !== undefined) {
clearTimeout(entry.timer);
}
}
this.#pendingAcks.clear();
this.#incomingFragments.clear();
this.#duplicateTable16.clear();
this.#duplicateTable64.clear();
}
/**
* Get next APS counter.
* HOT PATH: Optimized counter increment
* @returns Incremented APS counter (wraps at 255)
*/
/* @__INLINE__ */
nextCounter() {
this.#counter = (this.#counter + 1) & 0xff;
return this.#counter;
}
/**
* Get next ZDO sequence number.
* HOT PATH: Optimized counter increment
* @returns Incremented ZDO sequence number (wraps at 255)
*/
/* @__INLINE__ */
nextZDOSeqNum() {
this.#zdoSeqNum = (this.#zdoSeqNum + 1) & 0xff;
return this.#zdoSeqNum;
}
/**
* 05-3474-23 #4.4.11.1 (Application Link Key establishment)
*
* Get or generate application link key for a device pair
*
* SPEC COMPLIANCE NOTES:
* - ✅ Retrieves stored link key when present to satisfy apsDeviceKeyPairSet lookup
* - ✅ Derives fallback key from Trust Center link key when absent (per spec default)
* - ✅ Persists generated key via StackContext helper for future requests
* - ⚠️ Derived key currently mirrors TC key; unique per-pair derivation still TODO
* DEVICE SCOPE: Trust Center
*/
#getOrGenerateAppLinkKey(deviceA, deviceB) {
const existing = this.#context.getAppLinkKey(deviceA, deviceB);
if (existing !== undefined) {
return existing;
}
const derived = Buffer.from(this.#context.netParams.tcKey);
this.#context.setAppLinkKey(deviceA, deviceB, derived);
return derived;
}
/**
* 05-3474-23 #2.2.6.5 (APS duplicate rejection)
*
* Check whether an incoming APS frame is a duplicate and update the duplicate table accordingly.
*
* SPEC COMPLIANCE NOTES:
* - ✅ Uses {src64, dstEndpoint, clusterId, apsCounter} tuple per spec to detect duplicates
* - ✅ Applies configurable timeout window (DEFAULT ≈ 8s) after which entries expire
* - ✅ Tracks fragment block numbers explicitly so out-of-order fragment retransmissions are accepted
* - ✅ Drops duplicates before generating APS ACKs, matching required ordering
* - ⚠️ Duplicate table stored in-memory only; persistence across restart is not implemented
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*
* @returns true when the frame was already seen within the duplicate removal timeout.
*/
isDuplicateFrame(nwkHeader, apsHeader, now = Date.now()) {
if (apsHeader.counter === undefined) {
// skip check
return false;
}
const hasSource16 = nwkHeader.source16 !== undefined;
// prune expired duplicates, only for relevant table to avoid pointless looping for current frame
if (hasSource16) {
for (const [key, entry] of this.#duplicateTable16) {
if (entry.expiresAt <= now) {
this.#duplicateTable16.delete(key);
}
}
}
else {
for (const [key, entry] of this.#duplicateTable64) {
if (entry.expiresAt <= now) {
this.#duplicateTable64.delete(key);
}
}
}
const isFragmented = apsHeader.fragmentation !== undefined && apsHeader.fragmentation !== 0 /* ZigbeeAPSFragmentation.NONE */;
// frames are dropped in `processFrame` if neither source available
const entry = hasSource16 ? this.#duplicateTable16.get(nwkHeader.source16) : this.#duplicateTable64.get(nwkHeader.source64);
if (entry !== undefined && entry.counter === apsHeader.counter && entry.expiresAt > now) {
if (isFragmented) {
const blockNumber = apsHeader.fragBlockNumber ?? 0;
let fragments = entry.fragments;
if (fragments === undefined) {
fragments = new Set();
entry.fragments = fragments;
}
else if (fragments.has(blockNumber)) {
return true;
}
fragments.add(blockNumber);
entry.expiresAt = now + CONFIG_APS_DUPLICATE_TIMEOUT_MS;
return false;
}
return true;
}
const newEntry = {
counter: apsHeader.counter,
expiresAt: now + CONFIG_APS_DUPLICATE_TIMEOUT_MS,
};
if (isFragmented) {
newEntry.fragments = new Set([apsHeader.fragBlockNumber ?? 0]);
}
if (hasSource16) {
this.#duplicateTable16.set(nwkHeader.source16, newEntry);
}
else {
this.#duplicateTable64.set(nwkHeader.source64, newEntry);
}
return false;
}
/**
* 05-3474-23 #4.4.1 (APS data service)
*
* Send a Zigbee APS DATA frame and track pending ACK if necessary.
*
* SPEC COMPLIANCE NOTES:
* - ✅ Builds APS frame with ackRequest flag and delivery mode per parameters
* - ✅ Tracks pending acknowledgements per spec timeout (CONFIG_APS_ACK_WAIT_DURATION_MS ≈ 1.5s)
* - ✅ Applies fragmentation when payload exceeds APS maximum (CONFIG_APS_UNFRAGMENTED_PAYLOAD_MAX)
* - ⚠️ Fragment reassembly timer configurable but not spec-driven (CONFIG_APS_FRAGMENT_REASSEMBLY_TIMEOUT_MS)
* - ⚠️ Route discovery hint (nwkDiscoverRoute) passed to NWK handler without additional validation
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*
* @param finalPayload Encoded APS payload
* @param nwkDiscoverRoute NWK discovery mode
* @param nwkDest16 Destination short address (if known)
* @param nwkDest64 Destination IEEE (optional)
* @param apsDeliveryMode Delivery mode (unicast/group/broadcast)
* @param clusterId Cluster identifier
* @param profileId Profile identifier
* @param destEndpoint Destination endpoint
* @param sourceEndpoint Source endpoint
* @param group Group identifier (when group addressed)
* @returns The APS counter of the sent frame
*/
async sendData(finalPayload, nwkDiscoverRoute, nwkDest16, nwkDest64, apsDeliveryMode, clusterId, profileId, destEndpoint, sourceEndpoint, group) {
const params = {
finalPayload,
nwkDiscoverRoute,
nwkDest16,
nwkDest64,
apsDeliveryMode,
clusterId,
profileId,
destEndpoint,
sourceEndpoint,
group,
};
const apsCounter = this.nextCounter();
if (finalPayload.length > 100 /* ZigbeeAPSConsts.PAYLOAD_MAX_SIZE */) {
return await this.#sendFragmentedData(params, apsCounter);
}
const sendDest16 = await this.#sendDataInternal(params, apsCounter, 0);
if (sendDest16 !== undefined) {
this.#trackPendingAck(sendDest16, apsCounter, params);
}
return apsCounter;
}
/**
* 05-3474-23 #4.4.1 (APS data service)
*
* Send a Zigbee APS DATA frame.
* Throws if could not send.
*
* SPEC COMPLIANCE NOTES:
* - ✅ Encodes APS/NWK/MAC headers according to delivery mode and security requirements
* - ✅ Engages NWK source routing when available via `findBestSourceRoute`
* - ✅ Requests APS ACKs on non-broadcast destinations and tracks MAC pending bit for sleepy children
* - ⚠️ APS security flag hardcoded to false (link-key encryption pending future work)
* - ⚠️ Relies on caller to provide valid route discovery hint; no additional validation here
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*
* @param params
* @param apsCounter
* @param attempt
* @returns Destination short address (undefined for broadcast)
*/
async #sendDataInternal(params, apsCounter, attempt) {
const { finalPayload, nwkDiscoverRoute, apsDeliveryMode, clusterId, profileId, destEndpoint, sourceEndpoint, group } = params;
let { nwkDest16, nwkDest64 } = params;
const nwkSeqNum = this.#nwkHandler.nextSeqNum();
const macSeqNum = this.#macHandler.nextSeqNum();
let relayIndex;
let relayAddresses;
try {
[relayIndex, relayAddresses] = this.#nwkHandler.findBestSourceRoute(nwkDest16, nwkDest64);
}
catch (error) {
logger_js_1.logger.error(`=x=> APS DATA[seqNum=(${apsCounter}/${nwkSeqNum}/${macSeqNum}) attempt=${attempt} nwkDst=${nwkDest16}:${nwkDest64}] ${error.message}`, NS);
throw error;
}
if (nwkDest16 === undefined && nwkDest64 !== undefined) {
nwkDest16 = this.#context.deviceTable.get(nwkDest64)?.address16;
}
if (nwkDest16 === undefined) {
logger_js_1.logger.error(`=x=> APS DATA[seqNum=(${apsCounter}/${nwkSeqNum}/${macSeqNum}) attempt=${attempt} nwkDst=${nwkDest16}:${nwkDest64}] Invalid parameters`, NS);
throw new Error("Invalid parameters");
}
// update params as needed
params.nwkDest16 = nwkDest16;
params.nwkDest64 = nwkDest64;
const macDest16 = nwkDest16 < 65528 /* ZigbeeConsts.BCAST_MIN */ ? (relayAddresses?.[relayIndex] ?? nwkDest16) : 65535 /* ZigbeeMACConsts.BCAST_ADDR */;
logger_js_1.logger.debug(() => `===> APS DATA[seqNum=(${apsCounter}/${nwkSeqNum}/${macSeqNum})${attempt > 0 ? ` attempt=${attempt}` : ""} macDst16=${macDest16} nwkDst=${nwkDest16}:${nwkDest64} nwkDiscRte=${nwkDiscoverRoute} apsDlv=${apsDeliveryMode}]`, NS);
const isFragment = params.fragment !== undefined;
const apsHeader = {
frameControl: {
frameType: 0 /* ZigbeeAPSFrameType.DATA */,
deliveryMode: apsDeliveryMode,
ackFormat: false,
security: false, // TODO link key support
ackRequest: true,
extendedHeader: isFragment,
},
destEndpoint,
group,
clusterId,
profileId,
sourceEndpoint,
counter: apsCounter,
};
if (isFragment) {
const fragmentInfo = params.fragment;
const fragmentation = fragmentInfo.isFirst
? 1 /* ZigbeeAPSFragmentation.FIRST */
: fragmentInfo.isLast
? 3 /* ZigbeeAPSFragmentation.LAST */
: 2 /* ZigbeeAPSFragmentation.MIDDLE */;
apsHeader.fragmentation = fragmentation;
apsHeader.fragBlockNumber = fragmentInfo.blockNumber;
}
const apsFrame = (0, zigbee_aps_js_1.encodeZigbeeAPSFrame)(apsHeader, finalPayload);
const nwkFrame = (0, zigbee_nwk_js_1.encodeZigbeeNWKFrame)({
frameControl: {
frameType: 0 /* ZigbeeNWKFrameType.DATA */,
protocolVersion: 2 /* ZigbeeNWKConsts.VERSION_2007 */,
discoverRoute: nwkDiscoverRoute,
multicast: false,
security: true,
sourceRoute: relayIndex !== undefined,
extendedDestination: nwkDest64 !== undefined,
extendedSource: false,
endDeviceInitiator: false,
},
destination16: nwkDest16,
destination64: nwkDest64,
source16: 0 /* ZigbeeConsts.COORDINATOR_ADDRESS */,
radius: this.#context.decrementRadius(nwk_handler_js_1.CONFIG_NWK_MAX_HOPS),
seqNum: nwkSeqNum,
relayIndex,
relayAddresses,
}, apsFrame, {
control: {
level: 0 /* ZigbeeSecurityLevel.NONE */,
keyId: 1 /* ZigbeeKeyType.NWK */,
nonce: true,
reqVerifiedFc: false,
},
frameCounter: this.#context.nextNWKKeyFrameCounter(),
source64: this.#context.netParams.eui64,
keySeqNum: this.#context.netParams.networkKeySequenceNumber,
micLen: 4,
}, undefined);
const macFrame = (0, mac_js_1.encodeMACFrameZigbee)({
frameControl: {
frameType: 1 /* MACFrameType.DATA */,
securityEnabled: false,
framePending: group === undefined && nwkDest16 < 65528 /* ZigbeeConsts.BCAST_MIN */
? Boolean(this.#context.indirectTransmissions.get(nwkDest64 ?? this.#context.address16ToAddress64.get(nwkDest16))?.length)
: false,
ackRequest: macDest16 !== 65535 /* ZigbeeMACConsts.BCAST_ADDR */,
panIdCompression: true,
seqNumSuppress: false,
iePresent: false,
destAddrMode: 2 /* MACFrameAddressMode.SHORT */,
frameVersion: 0 /* MACFrameVersion.V2003 */,
sourceAddrMode: 2 /* MACFrameAddressMode.SHORT */,
},
sequenceNumber: macSeqNum,
destinationPANId: this.#context.netParams.panId,
destination16: macDest16,
// sourcePANId: undefined, // panIdCompression=true
source16: 0 /* ZigbeeConsts.COORDINATOR_ADDRESS */,
fcs: 0,
}, nwkFrame);
const result = await this.#macHandler.sendFrame(macSeqNum, macFrame, macDest16, undefined);
if (result === false) {
logger_js_1.logger.error(`=x=> APS DATA[seqNum=(${apsCounter}/${nwkSeqNum}/${macSeqNum}) attempt=${attempt} macDst16=${macDest16} nwkDst=${nwkDest16}:${nwkDest64}] Failed to send`, NS);
throw new Error("Failed to send");
}
if (macDest16 === 65535 /* ZigbeeMACConsts.BCAST_ADDR */) {
return undefined;
}
return nwkDest16;
}
/**
* 05-3474-23 #4.4.5 (APS fragmentation)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Splits payload into first/remaining chunks respecting fragment overhead constants
* - ✅ Stores fragmentation context to coordinate sequential block transmission
* - ✅ Requires unicast ACKs per spec before advancing to later blocks
* - ⚠️ Does not yet adapt fragment size based on MAC MTU or user configuration
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*/
async #sendFragmentedData(params, apsCounter) {
const payload = params.finalPayload;
if (payload.byteLength <= 100 /* ZigbeeAPSConsts.PAYLOAD_MAX_SIZE */) {
return apsCounter;
}
const baseParams = {
nwkDiscoverRoute: params.nwkDiscoverRoute,
nwkDest16: params.nwkDest16,
nwkDest64: params.nwkDest64,
apsDeliveryMode: params.apsDeliveryMode,
clusterId: params.clusterId,
profileId: params.profileId,
destEndpoint: params.destEndpoint,
sourceEndpoint: params.sourceEndpoint,
group: params.group,
};
const chunks = [];
let offset = 0;
let block = 0;
const firstChunkSize = Math.max(1, 48 /* ZigbeeAPSConsts.FRAGMENT_PAYLOAD_SIZE */ - 2 /* ZigbeeAPSConsts.FRAGMENT_FIRST_LENGTH_SIZE */);
while (offset < payload.byteLength) {
const size = block === 0 ? firstChunkSize : 48 /* ZigbeeAPSConsts.FRAGMENT_PAYLOAD_SIZE */;
const chunk = Buffer.from(payload.subarray(offset, offset + size));
chunks.push(chunk);
offset += chunk.byteLength;
block += 1;
}
if (chunks.length <= 1) {
throw new Error("APS fragmentation requires at least two chunks");
}
const context = {
baseParams,
chunks,
awaitingBlock: 0,
totalBlocks: chunks.length,
};
const { dest16, params: firstParams } = await this.#sendFragmentBlock(context, apsCounter, 0, 0);
if (dest16 === undefined) {
throw new Error("APS fragmentation requires unicast destination acknowledgments");
}
this.#trackPendingAck(dest16, apsCounter, firstParams, context);
return apsCounter;
}
#buildFragmentParams(context, blockNumber) {
const fragment = {
blockNumber,
isFirst: blockNumber === 0,
isLast: blockNumber === context.totalBlocks - 1,
};
return {
...context.baseParams,
finalPayload: context.chunks[blockNumber],
fragment,
};
}
async #sendFragmentBlock(context, apsCounter, blockNumber, attempt) {
const fragmentParams = this.#buildFragmentParams(context, blockNumber);
const dest16 = await this.#sendDataInternal(fragmentParams, apsCounter, attempt);
return { dest16, params: fragmentParams };
}
/**
* 05-3474-23 #4.4.5 (APS fragmentation)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Advances to next fragment only after prior block acknowledged
* - ✅ Reuses shared context to maintain block numbering and chunk references
* - ⚠️ Throws for broadcast destinations; spec restricts fragmentation to unicast
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*/
async #sendNextFragmentBlock(context, previousEntry) {
context.awaitingBlock += 1;
if (context.awaitingBlock >= context.totalBlocks) {
return;
}
const { dest16, params } = await this.#sendFragmentBlock(context, previousEntry.apsCounter, context.awaitingBlock, 0);
if (dest16 === undefined) {
throw new Error("APS fragmentation requires unicast destination acknowledgments");
}
this.#trackPendingAck(dest16, previousEntry.apsCounter, params, context);
}
/**
* 05-3474-23 #4.4.5 (APS fragmentation reassembly)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Initializes fragment state on FIRST block and records meta fields
* - ✅ Tracks expected block count from LAST fragment index
* - ✅ Clears extended header bits once reassembly completes per spec requirement
* - ⚠️ Reassembly timeout configurable via constant (30s); spec leaves timing vendor-specific
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*/
#handleIncomingFragment(data, nwkHeader, apsHeader) {
const now = Date.now();
this.#pruneExpiredFragmentStates(now);
const blockNumber = apsHeader.fragBlockNumber ?? 0;
const key = this.#makeFragmentKey(nwkHeader, apsHeader);
const fragmentation = apsHeader.fragmentation ?? 0 /* ZigbeeAPSFragmentation.NONE */;
if (fragmentation === 1 /* ZigbeeAPSFragmentation.FIRST */) {
const state = {
chunks: new Map([[blockNumber, Buffer.from(data)]]),
lastActivity: now,
source16: nwkHeader.source16,
source64: nwkHeader.source64,
destEndpoint: apsHeader.destEndpoint,
profileId: apsHeader.profileId,
clusterId: apsHeader.clusterId,
counter: apsHeader.counter ?? 0,
};
this.#incomingFragments.set(key, state);
return undefined;
}
const state = this.#incomingFragments.get(key);
if (state === undefined) {
return undefined;
}
state.chunks.set(blockNumber, Buffer.from(data));
state.lastActivity = now;
if (fragmentation === 3 /* ZigbeeAPSFragmentation.LAST */) {
state.expectedBlocks = blockNumber + 1;
}
if (state.expectedBlocks === undefined || state.chunks.size < state.expectedBlocks) {
return undefined;
}
const buffers = [];
for (let block = 0; block < state.expectedBlocks; block += 1) {
const chunk = state.chunks.get(block);
if (chunk === undefined) {
return undefined;
}
buffers.push(chunk);
}
this.#incomingFragments.delete(key);
apsHeader.frameControl.extendedHeader = false;
apsHeader.fragmentation = undefined;
apsHeader.fragBlockNumber = undefined;
apsHeader.fragACKBitfield = undefined;
return Buffer.concat(buffers);
}
#makeFragmentKey(nwkHeader, apsHeader) {
const source = nwkHeader.source64 !== undefined ? `64:${nwkHeader.source64}` : `16:${nwkHeader.source16 ?? 0xffff}`;
const profile = apsHeader.profileId ?? 0;
const cluster = apsHeader.clusterId ?? 0;
const sourceEndpoint = apsHeader.sourceEndpoint ?? 0xff;
const destEndpoint = apsHeader.destEndpoint ?? 0xff;
const counter = apsHeader.counter ?? 0;
return `${source}:${profile}:${cluster}:${sourceEndpoint}:${destEndpoint}:${counter}`;
}
#pruneExpiredFragmentStates(now) {
for (const [key, state] of this.#incomingFragments) {
if (now - state.lastActivity >= CONFIG_APS_FRAGMENT_REASSEMBLY_TIMEOUT_MS) {
this.#incomingFragments.delete(key);
}
}
}
#updateSourceRouteForChild(child16, parent16, parent64) {
if (parent16 === undefined) {
return;
}
try {
const [, parentRelays] = this.#nwkHandler.findBestSourceRoute(parent16, parent64);
if (parentRelays) {
this.#context.sourceRouteTable.set(child16, [this.#nwkHandler.createSourceRouteEntry(parentRelays, parentRelays.length + 1)]);
}
else {
this.#context.sourceRouteTable.set(child16, [this.#nwkHandler.createSourceRouteEntry([parent16], 2)]);
}
}
catch {
/* ignore (no known route yet) */
}
}
/**
* 05-3474-23 #4.4.2.3 (APS acknowledgement management)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Starts ack-wait timer using spec default (~1.5 s) and resets on retransmit
* - ✅ Stores fragment context so subsequent blocks send only after ACK
* - ⚠️ Pending table keyed by {dest16,counter}; no IEEE64 fallback if short address unknown
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*/
#trackPendingAck(dest16, apsCounter, params, fragment) {
const key = `${dest16}:${apsCounter}`;
const existing = this.#pendingAcks.get(key);
if (existing?.timer !== undefined) {
clearTimeout(existing.timer);
}
this.#pendingAcks.set(key, {
params,
apsCounter,
dest16,
retries: 0,
timer: setTimeout(async () => {
await this.#handleAckTimeout(key);
}, exports.CONFIG_APS_ACK_WAIT_DURATION_MS),
fragment,
});
}
/**
* 05-3474-23 #4.4.2.3 (APS acknowledgement management)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Retries DATA up to CONFIG_APS_MAX_FRAME_RETRIES per spec guidance (default 3)
* - ✅ Re-arms ack timer after each retransmission
* - ⚠️ Does not escalate failure beyond logging; higher layers must react to exhausted retries
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*/
async #handleAckTimeout(key) {
const entry = this.#pendingAcks.get(key);
if (entry === undefined) {
return;
}
if (entry.retries >= exports.CONFIG_APS_MAX_FRAME_RETRIES) {
this.#pendingAcks.delete(key);
logger_js_1.logger.error(`=x=> APS DATA[apsCounter=${entry.apsCounter} dest16=${entry.dest16}] Retries exhausted`, NS);
return;
}
entry.retries += 1;
try {
await this.#sendDataInternal(entry.params, entry.apsCounter, entry.retries);
}
catch (error) {
this.#pendingAcks.delete(key);
logger_js_1.logger.warning(() => `=x=> APS DATA retry failed[apsCounter=${entry.apsCounter} dest16=${entry.dest16} attempt=${entry.retries}] ${error.message}`, NS);
return;
}
entry.timer = setTimeout(async () => {
await this.#handleAckTimeout(key);
}, exports.CONFIG_APS_ACK_WAIT_DURATION_MS);
}
/**
* 05-3474-23 #4.4.2.3 (APS acknowledgement management)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Matches ACKs using source short address and APS counter per spec tuple
* - ✅ Clears timers promptly to avoid dangling callbacks
* - ✅ Triggers next fragment block when outstanding
* - ⚠️ No handling for duplicate ACKs; silently ignored once entry removed
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*/
async #resolvePendingAck(nwkHeader, apsHeader) {
if (apsHeader.counter === undefined) {
return;
}
let source16 = nwkHeader.source16;
if (source16 === undefined && nwkHeader.source64 !== undefined) {
source16 = this.#context.deviceTable.get(nwkHeader.source64)?.address16;
}
if (source16 === undefined) {
return;
}
const key = `${source16}:${apsHeader.counter}`;
const entry = this.#pendingAcks.get(key);
if (entry === undefined) {
return;
}
if (entry.timer !== undefined) {
clearTimeout(entry.timer);
}
this.#pendingAcks.delete(key);
logger_js_1.logger.debug(() => `<=== APS ACK[src16=${source16} apsCounter=${apsHeader.counter} dstEp=${apsHeader.sourceEndpoint} clusterId=${apsHeader.clusterId}]`, NS);
if (entry.fragment !== undefined) {
await this.#sendNextFragmentBlock(entry.fragment, entry);
}
}
/**
* 05-3474-23 #4.4.2.3 (APS acknowledgement)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Mirrors counter and cluster metadata per spec Table 4-10
* - ✅ Selects unicast delivery mode and suppresses retransmit (ackRequest=false)
* - ✅ Reuses NWK/MAC sequence numbers from incoming frame to satisfy reliability requirements
* - ⚠️ Fragment ACK format limited to simple bitfield (supports first block only)
* - ⚠️ Does not retry failed acknowledgements; relies on NWK retransmissions
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*/
async sendACK(macHeader, nwkHeader, apsHeader) {
logger_js_1.logger.debug(() => `===> APS ACK[dst16=${nwkHeader.source16} apsCounter=${apsHeader.counter} dstEp=${apsHeader.sourceEndpoint} clusterId=${apsHeader.clusterId}]`, NS);
let nwkDest16 = nwkHeader.source16;
const nwkDest64 = nwkHeader.source64;
let relayIndex;
let relayAddresses;
try {
[relayIndex, relayAddresses] = this.#nwkHandler.findBestSourceRoute(nwkDest16, nwkDest64);
}
catch (error) {
logger_js_1.logger.debug(() => `=x=> APS ACK[dst16=${nwkDest16} seqNum=${nwkHeader.seqNum}] ${error.message}`, NS);
return;
}
if (nwkDest16 === undefined && nwkDest64 !== undefined) {
nwkDest16 = this.#context.deviceTable.get(nwkDest64)?.address16;
}
if (nwkDest16 === undefined) {
logger_js_1.logger.debug(() => `=x=> APS ACK[dst16=${nwkHeader.source16} seqNum=${nwkHeader.seqNum} dstEp=${apsHeader.sourceEndpoint} clusterId=${apsHeader.clusterId}] Unknown destination`, NS);
return;
}
const macDest16 = nwkDest16 < 65528 /* ZigbeeConsts.BCAST_MIN */ ? (relayAddresses?.[relayIndex] ?? nwkDest16) : 65535 /* ZigbeeMACConsts.BCAST_ADDR */;
const ackNeedsFragmentInfo = apsHeader.frameControl.extendedHeader && apsHeader.fragmentation !== undefined && apsHeader.fragmentation !== 0 /* ZigbeeAPSFragmentation.NONE */;
const ackHeader = {
frameControl: {
frameType: 2 /* ZigbeeAPSFrameType.ACK */,
deliveryMode: 0 /* ZigbeeAPSDeliveryMode.UNICAST */,
ackFormat: false,
security: false,
ackRequest: false,
extendedHeader: ackNeedsFragmentInfo,
},
destEndpoint: apsHeader.sourceEndpoint,
clusterId: apsHeader.clusterId,
profileId: apsHeader.profileId,
sourceEndpoint: apsHeader.destEndpoint,
counter: apsHeader.counter,
};
if (ackNeedsFragmentInfo) {
ackHeader.fragmentation = 1 /* ZigbeeAPSFragmentation.FIRST */;
ackHeader.fragBlockNumber = apsHeader.fragBlockNumber ?? 0;
ackHeader.fragACKBitfield = 0x01;
}
const ackAPSFrame = (0, zigbee_aps_js_1.encodeZigbeeAPSFrame)(ackHeader, Buffer.alloc(0));
const ackNWKFrame = (0, zigbee_nwk_js_1.encodeZigbeeNWKFrame)({
frameControl: {
frameType: 0 /* ZigbeeNWKFrameType.DATA */,
protocolVersion: 2 /* ZigbeeNWKConsts.VERSION_2007 */,
discoverRoute: 0 /* ZigbeeNWKRouteDiscovery.SUPPRESS */,
multicast: false,
security: true,
sourceRoute: relayIndex !== undefined,
extendedDestination: false,
extendedSource: false,
endDeviceInitiator: false,
},
destination16: nwkHeader.source16,
source16: nwkHeader.destination16,
radius: this.#context.decrementRadius(nwkHeader.radius ?? nwk_handler_js_1.CONFIG_NWK_MAX_HOPS),
seqNum: nwkHeader.seqNum,
relayIndex,
relayAddresses,
}, ackAPSFrame, {
control: {
level: 0 /* ZigbeeSecurityLevel.NONE */,
keyId: 1 /* ZigbeeKeyType.NWK */,
nonce: true,
reqVerifiedFc: false,
},
frameCounter: this.#context.nextNWKKeyFrameCounter(),
source64: this.#context.netParams.eui64,
keySeqNum: this.#context.netParams.networkKeySequenceNumber,
micLen: 4,
}, undefined);
const ackMACFrame = (0, mac_js_1.encodeMACFrameZigbee)({
frameControl: {
frameType: 1 /* MACFrameType.DATA */,
securityEnabled: false,
framePending: Boolean(this.#context.indirectTransmissions.get(nwkDest64 ?? this.#context.address16ToAddress64.get(nwkDest16))?.length),
ackRequest: true,
panIdCompression: true,
seqNumSuppress: false,
iePresent: false,
destAddrMode: 2 /* MACFrameAddressMode.SHORT */,
frameVersion: 0 /* MACFrameVersion.V2003 */,
sourceAddrMode: 2 /* MACFrameAddressMode.SHORT */,
},
sequenceNumber: macHeader.sequenceNumber,
destinationPANId: macHeader.destinationPANId,
destination16: macDest16,
// sourcePANId: undefined, // panIdCompression=true
source16: 0 /* ZigbeeConsts.COORDINATOR_ADDRESS */,
fcs: 0,
}, ackNWKFrame);
await this.#macHandler.sendFrame(macHeader.sequenceNumber, ackMACFrame, macHeader.source16, undefined);
}
/**
* 05-3474-23 #4.4 (APS layer processing)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Handles DATA, ACK, INTERPAN frame types per spec definitions
* - ✅ Performs duplicate rejection using APS counter + source addressing
* - ✅ Performs fragmentation reassembly and forwards completed payloads upward
* - ⚠️ INTERPAN frames not supported (throws) - spec optional for coordinators
* - ⚠️ Fragment reassembly lacks payload size guard (tracked via CONFIG_APS_FRAGMENT_REASSEMBLY_TIMEOUT_MS)
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*/
async processFrame(data, macHeader, nwkHeader, apsHeader, lqa) {
switch (apsHeader.frameControl.frameType) {
case 2 /* ZigbeeAPSFrameType.ACK */: {
// ACKs should never contain a payload
await this.#resolvePendingAck(nwkHeader, apsHeader);
return;
}
case 0 /* ZigbeeAPSFrameType.DATA */:
case 3 /* ZigbeeAPSFrameType.INTERPAN */: {
if (data.byteLength < 1) {
return;
}
if (apsHeader.frameControl.extendedHeader &&
apsHeader.fragmentation !== undefined &&
apsHeader.fragmentation !== 0 /* ZigbeeAPSFragmentation.NONE */) {
const reassembled = this.#handleIncomingFragment(data, nwkHeader, apsHeader);
if (reassembled === undefined) {
return;
}
data = reassembled;
}
logger_js_1.logger.debug(() => `<=== APS DATA[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} seqNum=${nwkHeader.seqNum} profileId=${apsHeader.profileId} clusterId=${apsHeader.clusterId} srcEp=${apsHeader.sourceEndpoint} dstEp=${apsHeader.destEndpoint} bcast=${macHeader.destination16 === 65535 /* ZigbeeMACConsts.BCAST_ADDR */ || (nwkHeader.destination16 !== undefined && nwkHeader.destination16 >= 65528 /* ZigbeeConsts.BCAST_MIN */)}]`, NS);
if (apsHeader.profileId === 0 /* ZigbeeConsts.ZDO_PROFILE_ID */) {
if (apsHeader.clusterId === 19 /* ZigbeeConsts.END_DEVICE_ANNOUNCE */) {
let offset = 1; // skip seq num
const address16 = data.readUInt16LE(offset);
offset += 2;
const address64 = data.readBigUInt64LE(offset);
offset += 8;
const capabilities = data.readUInt8(offset);
offset += 1;
const device = this.#context.deviceTable.get(address64);
if (device === undefined) {
// unknown device, should have been added by `associate`, something's not right, ignore it
return;
}
const decodedCap = (0, mac_js_1.decodeMACCapabilities)(capabilities);
if (device.address16 !== address16) {
this.#context.address16ToAddress64.delete(device.address16);
this.#context.address16ToAddress64.set(address16, address64);
device.address16 = address16;
}
// just in case
device.capabilities = decodedCap;
await this.#context.savePeriodicState();
// TODO: ideally, this shouldn't trigger (prevents early interview process from app) until AFTER authorized=true
setImmediate(() => {
// if device is authorized, it means it completed the TC link key update, so, a rejoin
// TODO: could flip authorized to true before the announce and count as rejoin when it shouldn't
if (device.authorized) {
this.#callbacks.onDeviceRejoined(address16, address64, decodedCap);
}
else {
this.#callbacks.onDeviceJoined(address16, address64, decodedCap);
}
});
}
else {
const isRequest = (apsHeader.clusterId & 0x8000) === 0;
if (isRequest) {
if (this.isZDORequestForCoordinator(apsHeader.clusterId, nwkHeader.destination16, nwkHeader.destination64, data)) {
await this.respondToCoordinatorZDORequest(data, apsHeader.clusterId, nwkHeader.source16, nwkHeader.source64);
}
// don't emit received ZDO requests
return;
}
}
}
setImmediate(() => {
// TODO: always lookup source64 if undef?
this.#callbacks.onFrame(nwkHeader.source16, nwkHeader.source64, apsHeader, data, lqa);
});
break;
}
case 1 /* ZigbeeAPSFrameType.CMD */: {
await this.processCommand(data, macHeader, nwkHeader, apsHeader);
break;
}
default: {
throw new Error(`Illegal frame type ${apsHeader.frameControl.frameType}`);
}
}
}
// #region Commands
/**
* 05-3474-23 #4.4.11 (APS command frames)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Encodes APS command header with appropriate delivery mode and security bit per parameters
* - ✅ Integrates with NWK/ MAC handlers for routing + source routing
* - ✅ Supports APS security header injection (LOAD/TRANSPORT keys) as required by TC flows
* - ⚠️ disableACKRequest used for certain commands (e.g., TRANSPORT_KEY) despite spec recommending ACKs
* - ⚠️ TLV extensions not yet supported (R23 features)
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*
* @param cmdId APS command identifier
* @param finalPayload Fully encoded APS command payload (including cmdId)
* @param nwkDiscoverRoute NWK discovery mode
* @param nwkSecurity Whether to apply NWK security
* @param nwkDest16 Destination network address
* @param nwkDest64 Destination IEEE address (optional)
* @param apsDeliveryMode Delivery mode (unicast/broadcast)
* @param apsSecurityHeader Optional APS security header definition
* @param disableACKRequest Whether to suppress APS ACK request
* @returns True if success sending (or indirect transmission)
*/
async sendCommand(cmdId, finalPayload, nwkDiscoverRoute, nwkSecurity, nwkDest16, nwkDest64, apsDeliveryMode, apsSecurityHeader, disableACKRequest = false) {
let nwkSecurityHeader;
if (nwkSecurity) {
nwkSecurityHeader = {
control: {
level: 0 /* ZigbeeSecurityLevel.NONE */,
keyId: 1 /* ZigbeeKeyType.NWK */,
nonce: true,
reqVerifiedFc: false,
},
frameCounter: this.#context.nextNWKKeyFrameCounter(),
source64: this.#context.netParams.eui64,
keySeqNum: this.#context.netParams.networkKeySequenceNumber,
micLen: 4,
};
}
const apsCounter = this.nextCounter();
const nwkSeqNum = this.#nwkHandler.nextSeqNum();
const macSeqNum = this.#macHandler.nextSeqNum();
let relayIndex;
let relayAddresses;
try {
[relayIndex, relayAddresses] = this.#nwkHandler.findBestSourceRoute(nwkDest16, nwkDest64);
}
catch (error) {
logger_js_1.logger.error(`=x=> APS CMD[seqNum=(${apsCounter}/${nwkSeqNum}/${macSeqNum}) cmdId=${cmdId} nwkDst=${nwkDest16}:${nwkDest64}] ${error.message}`, NS);
return false;
}
if (nwkDest16 === undefined && nwkDest64 !== undefined) {
nwkDest16 = this.#context.deviceTable.get(nwkDest64)?.address16;
}
if (nwkDest16 === undefined) {
logger_js_1.logger.error(`=x=> APS CMD[seqNum=(${apsCounter}/${nwkSeqNum}/${macSeqNum}) cmdId=${cmdId} nwkDst=${nwkDest16}:${nwkDest64} nwkDiscRte=${nwkDiscoverRoute} nwkSec=${nwkSecurity} apsDlv=${apsDeliveryMode} apsSec=${apsSecurityHeader !== undefined}]`, NS);
return false;
}
const macDest16 = nwkDest16 < 65528 /* ZigbeeConsts.BCAST_MIN */ ? (relayAddresses?.[relayIndex] ?? nwkDest16) : 65535 /* ZigbeeMACConsts.BCAST_ADDR */;
logger_js_1.logger.debug(() => `===> APS CMD[seqNum=(${apsCounter}/${nwkSeqNum}/${macSeqNum}) cmdId=${cmdId} macDst16=${macDest16} nwkDst=${nwkDest16}:${nwkDest64} nwkDiscRte=${nwkDiscoverRoute} nwkSec=${nwkSecurity} apsDlv=${apsDeliveryMode} apsSec=${apsSecurityHeader !== undefined}]`, NS);
const apsFrame = (0, zigbee_aps_js_1.encodeZigbeeAPSFrame)({
frameControl: {
frameType: 1 /* ZigbeeAPSFrameType.CMD */,
deliveryMode: apsDeliveryMode,
ackFormat: false,
security: apsSecurityHeader !== undefined,
// XXX: spec says all should request ACK except TUNNEL, but vectors show not a lot of stacks respect that, what's best?
ackRequest: cmdId !== 14 /* ZigbeeAPSCommandId.TUNNEL */ && !disableACKRequest,
extendedHeader: false,
},
counter: apsCounter,
}, finalPayload, apsSecurityHeader, undefined);
const nwkFrame = (0, zigbee_nwk_js_1.encodeZigbeeNWKFrame)({
frameControl: {
frameType: 0 /* ZigbeeNWKFrameType.DATA */,
protocolVersion: 2 /* ZigbeeNWKConsts.VERSION_2007 */,
discoverRoute: nwkDiscoverRoute,
multicast: false,
security: nwkSecurity,
sourceRoute: relayIndex !== undefined,
extendedDestination: nwkDest64 !== undefined,
extendedSource: false,
endDeviceInitiator: false,
},
destination16: nwkDest16,
destination64: nwkDest64,
source16: 0 /* ZigbeeConsts.COORDINATOR_ADDRESS */,
radius: this.#context.decrementRadius(nwk_handler_js_1.CONFIG_NWK_MAX_HOPS),
seqNum: nwkSeqNum,
relayIndex,
relayAddresses,
}, apsFrame, nwkSecurityHeader, undefined);
const macFrame = (0, mac_js_1.encodeMACFrameZigbee)({
frameControl: {
frameType: 1 /* MACFrameType.DATA */,
securityEnabled: false,
framePending: Boolean(this.#context.indirectTransmissions.get(nwkDest64 ?? this.#context.address16ToAddress64.get(nwkDest16))?.length),
ackRequest: macDest16 !== 65535 /* ZigbeeMACConsts.BCAST_ADDR */,
panIdCompression: true,
seqNumSuppress: false,
iePresent: false,
destAddrMode: 2 /* MACFrameAddressMode.SHORT */,
frameVersion: 0 /* MACFrameVersion.V2003 */,
sourceAddrMode: 2 /* MACFrameAddressMode.SHORT */,
},
sequenceNumber: macSeqNum,
destinationPANId: this.#context.netParams.panId,
destination16: macDest16,
// sourcePANId: undefined, // panIdCompression=true
source16: 0 /* ZigbeeConsts.COORDINATOR_ADDRESS */,
fcs: 0,
}, nwkFrame);
const result = await this.#macHandler.sendFrame(macSeqNum, macFrame, macDest16, undefined);
return result !== false;
}
/**
* 05-3474-23 #4.4.11 (APS command processing)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Dispatches APS command IDs to the appropriate handler per Table 4-28
* - ✅ Logs unsupported commands for diagnostics without crashing the stack
* - ✅ Passes MAC/NWK headers to downstream handlers for security context decisions
* - ⚠️ TLV parsing for extended commands still TODO (handlers emit TODO markers)
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*/
async processCommand(data, macHeader, nwkHeader, apsHeader) {
let offset = 0;
const cmdId = data.readUInt8(offset);
offset += 1;
switch (cmdId) {
case 5 /* ZigbeeAPSCommandId.TRANSPORT_KEY */: {
offset = this.processTransportKey(data, offset, macHeader, nwkHeader, apsHeader);
break;
}
case 6 /* ZigbeeAPSCommandId.UPDATE_DEVICE */: {
offset = await this.processUpdateDevice(data, offset, macHeader, nwkHeader, apsHeader);
break;
}
case 7 /* ZigbeeAPSCommandId.REMOVE_DEVICE */: {
offset = await this.processRemoveDevice(data, offset, macHeader, nwkHeader, apsHeader);
break;
}
case 8 /* ZigbeeAPSCommandId.REQUEST_KEY */: {
offset = await this.processRequestKey(data, offset, macHeader, nwkHeader, apsHeader);
break;
}
case 9 /* ZigbeeAPSCommandId.SWITCH_KEY */: {
offset = this.processSwitchKey(data, offset, macHeader, nwkHeader, apsHeader);
break;
}
case 14 /* ZigbeeAPSCommandId.TUNNEL */: {
offset = this.processTunnel(data, offset, macHeader, nwkHeader, apsHeader);
break;
}
case 15 /* ZigbeeAPSCommandId.VERIFY_KEY */: {
offset = await this.processVerifyKey(data, offset, macHeader, nwkHeader, apsHeader);
break;
}
case 16 /* ZigbeeAPSCommandId.CONFIRM_KEY */: {
offset = this.processConfirmKey(data, offset, macHeader, nwkHeader, apsHeader);
break;
}
case 17 /* ZigbeeAPSCommandId.RELAY_MESSAGE_DOWNSTREAM */: {
offset = this.processRelayMessageDownstream(data, offset, macHeader, nwkHeader, apsHeader);
break;
}
case 18 /* ZigbeeAPSCommandId.RELAY_MESSAGE_UPSTREAM */: {
offset = this.processRelayMessageUpstream(data, offset, macHeader, nwkHeader, apsHeader);
break;
}
default: {
logger_js_1.logger.warning(`<=x= APS CMD[cmdId=${cmdId} macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64}] Unsupported`, NS);
return;
}
}
// excess data in packet
// if (offset < data.byteLength) {
// logger.debug(() => `<=== APS CMD contained more data: ${data.toString('hex')}`, NS);
// }
}
/**
* 05-3474-23 #4.4.11.1
*
* SPEC COMPLIANCE NOTES:
* - ✅ Handles all mandated key types (NWK, Trust Center, Application) and logs metadata
* - ✅ Stages pending network key when addressed to coordinator or wildcard destination
* - ✅ Preserves raw key material for subsequent SWITCH_KEY activation
* - ⚠️ TLV extensions for enhanced security fields remain unparsed (TODO markers)
* - ⚠️ Application key handling currently limited to storage; partner attribute updates pending
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*/
processTransportKey(data, offset, macHeader, nwkHeader, _apsHeader) {
const keyType = data.readUInt8(offset);
offset += 1;
const key = data.subarray(offset, offset + 16 /* ZigbeeAPSConsts.CMD_KEY_LENGTH */);
offset += 16 /* ZigbeeAPSConsts.CMD_KEY_LENGTH */;
switch (keyType) {
case 1 /* ZigbeeAPSConsts.CMD_KEY_STANDARD_NWK */:
case 5 /* ZigbeeAPSConsts.CMD_KEY_HIGH_SEC_NWK */: {
const seqNum = data.readUInt8(offset);
offset += 1;
const destination = data.readBigUInt64LE(offset);
offset += 8;
const source = data.readBigUInt64LE(offset);
offset += 8;
logger_js_1.logger.debug(() => `<=== APS TRANSPORT_KEY[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} type=${keyType} key=${key} seqNum=${seqNum} dst64=${destination}