UNPKG

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
"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}