zigbee-on-host
Version:
Zigbee stack designed to run on a host and communicate with a radio co-processor (RCP)
945 lines • 86.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.NWKHandler = exports.CONFIG_NWK_ED_TIMEOUT_DEFAULT = exports.CONFIG_NWK_MAX_HOPS = void 0;
const logger_js_1 = require("../utils/logger.js");
const mac_js_1 = require("../zigbee/mac.js");
const zigbee_nwk_js_1 = require("../zigbee/zigbee-nwk.js");
const stack_context_js_1 = require("../zigbee-stack/stack-context.js");
const NS = "nwk-handler";
/** The number of OctetDurations until a route discovery expires. */
// const CONFIG_NWK_ROUTE_DISCOVERY_TIME = 0x4c4b4; // 0x2710 msec on 2.4GHz
/** The maximum depth of the network (number of hops) used for various calculations of network timing and limitations. */
const CONFIG_NWK_MAX_DEPTH = 15;
exports.CONFIG_NWK_MAX_HOPS = CONFIG_NWK_MAX_DEPTH * 2;
/** The number of network layer retries on unicast messages that are attempted before reporting the result to the higher layer. */
// const CONFIG_NWK_UNICAST_RETRIES = 3;
/** The delay between network layer retries. (ms) */
// const CONFIG_NWK_UNICAST_RETRY_DELAY = 50;
/** The total delivery time for a broadcast transmission to be delivered to all RxOnWhenIdle=TRUE devices in the network. (sec) */
// const CONFIG_NWK_BCAST_DELIVERY_TIME = 9;
/** The time between link status command frames (msec) */
const CONFIG_NWK_LINK_STATUS_PERIOD = 15000;
/** Avoid synchronization with other nodes by randomizing `CONFIG_NWK_LINK_STATUS_PERIOD` with this (msec) */
const CONFIG_NWK_LINK_STATUS_JITTER = 1000;
/** The number of missed link status command frames before resetting the link costs to zero. */
const CONFIG_NWK_ROUTER_AGE_LIMIT = 3;
/** This is an index into Table 3-54. It indicates the default timeout in minutes for any end device that does not negotiate a different timeout value. */
exports.CONFIG_NWK_ED_TIMEOUT_DEFAULT = 8;
/**
* Default parent info byte for end device timeout negotiation
* - Bit 0 MAC Data Poll Keepalive Supported
* - Bit 1 End Device Timeout Request Keepalive Supported
* - Bit 2 Power Negotiation Support
*/
const CONFIG_NWK_ED_TIMEOUT_PARENT_INFO_DEFAULT = 0b00000111;
/** The time between concentrator route discoveries. (msec) */
const CONFIG_NWK_CONCENTRATOR_DISCOVERY_TIME = 60000;
/** The hop count radius for concentrator route discoveries. */
const CONFIG_NWK_CONCENTRATOR_RADIUS = exports.CONFIG_NWK_MAX_HOPS;
/** The number of delivery failures that trigger an immediate concentrator route discoveries. */
const CONFIG_NWK_CONCENTRATOR_DELIVERY_FAILURE_THRESHOLD = 1;
/** The time before a route is considered stale and less preferred (msec) */
const CONFIG_NWK_ROUTE_STALENESS_TIME = 120000;
/** The maximum age before a route is considered expired and removed (msec) */
const CONFIG_NWK_ROUTE_EXPIRY_TIME = 300000;
/** The maximum number of consecutive failures before a route is blacklisted (count) */
const CONFIG_NWK_ROUTE_MAX_FAILURES = 3;
/** Minimum time between many-to-one route request broadcasts to avoid flooding (msec) */
const CONFIG_NWK_CONCENTRATOR_MIN_TIME = 10000;
/** The maximum number of hops in a source route. */
const CONFIG_NWK_MAX_SOURCE_ROUTE = 0x0c;
// export const CONFIG_NWK_MAX_ROUTERS = 6; // ignored, no limit with host-based
// export const CONFIG_NWK_MAX_CHILDREN = 20; // ignored, no limit with host-based
/**
* NWK Handler - Zigbee Network Layer Operations
*
* Handles all Zigbee NWK (Network) layer operations including:
* - NWK command transmission and processing
* - Route discovery and management
* - Source routing
* - Link status
* - Leave and rejoin operations
* - Network commissioning
*/
class NWKHandler {
#context;
#macHandler;
#callbacks;
// Private counters (start at 0, first call returns 1)
#seqNum = 0;
#routeRequestId = 0;
#linkStatusTimeout;
#manyToOneRouteRequestTimeout;
/** Time of last many-to-one route request */
#lastMTORRTime = 0;
constructor(context, macHandler, callbacks) {
this.#context = context;
this.#macHandler = macHandler;
this.#callbacks = callbacks;
}
async start() {
this.#linkStatusTimeout = setTimeout(this.sendPeriodicZigbeeNWKLinkStatus.bind(this), CONFIG_NWK_LINK_STATUS_PERIOD + Math.random() * CONFIG_NWK_LINK_STATUS_JITTER);
this.#manyToOneRouteRequestTimeout = setTimeout(this.sendPeriodicManyToOneRouteRequest.bind(this), CONFIG_NWK_CONCENTRATOR_DISCOVERY_TIME);
// ignore stale on first trigger
await this.sendPeriodicZigbeeNWKLinkStatus(true);
await this.sendPeriodicManyToOneRouteRequest();
}
stop() {
clearTimeout(this.#linkStatusTimeout);
this.#linkStatusTimeout = undefined;
clearTimeout(this.#manyToOneRouteRequestTimeout);
this.#manyToOneRouteRequestTimeout = undefined;
}
/**
* Get next NWK sequence number.
* HOT PATH: Optimized counter increment
* @returns Incremented NWK sequence number (wraps at 255)
*/
/* @__INLINE__ */
nextSeqNum() {
this.#seqNum = (this.#seqNum + 1) & 0xff;
return this.#seqNum;
}
/**
* Get next route request ID.
* HOT PATH: Optimized counter increment
* @returns Incremented route request ID (wraps at 255)
*/
/* @__INLINE__ */
nextRouteRequestId() {
this.#routeRequestId = (this.#routeRequestId + 1) & 0xff;
return this.#routeRequestId;
}
// #region Route Management
/**
* 05-3474-23 #3.4.8 (Link Status command)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Sends periodic LINK_STATUS commands at 15s interval with jitter per spec guidance for link cost maintenance
* - ✅ Derives incoming/outgoing cost from neighbor LQA and routing metrics (spec Table 3-20)
* - ✅ Resets timer using refresh() to maintain continuous reporting while handler active
* - ⚠️ Aggregated cost calculation includes implementation-specific LQA penalty (documented)
* - ✅ Enforces CONFIG_NWK_ROUTER_AGE_LIMIT by zeroing costs after consecutive misses per spec
* DEVICE SCOPE: Coordinator, routers (N/A)
*/
async sendPeriodicZigbeeNWKLinkStatus(ignoreStale = false) {
const links = [];
for (const [device64, entry] of this.#context.deviceTable.entries()) {
if (entry.neighbor) {
if (entry.capabilities?.deviceType === 1 /* ZigbeeMACConsts.DEVICE_TYPE_FFD */) {
const nextMisses = (entry.linkStatusMisses ?? 0) + 1; // XXX: technically uint16, doesn't matter with host-based
entry.linkStatusMisses = nextMisses;
if (nextMisses > CONFIG_NWK_ROUTER_AGE_LIMIT) {
links.push({
address: entry.address16,
incomingCost: 0,
outgoingCost: 0,
});
continue;
}
}
try {
// calculate cost based on path cost and recent link quality
const [, , pathCost] = this.findBestSourceRoute(entry.address16, device64, ignoreStale);
let linkCost = pathCost ?? 1;
// adjust cost based on recent LQA (link quality assessment) only if we have data
if (entry.recentLQAs.length > 0) {
const avgLQA = entry.recentLQAs.reduce((sum, lqa) => sum + lqa, 0) / entry.recentLQAs.length;
// only apply penalty if avgLQA is valid
if (!Number.isNaN(avgLQA)) {
// LQA range [0..255], convert to cost penalty [0..7]
// high LQA (good link) = low penalty, low LQA (bad link) = high penalty
const lqaPenalty = Math.max(0, Math.min(7, Math.floor((255 - avgLQA) / 36)));
linkCost = Math.min(7, linkCost + lqaPenalty);
}
}
links.push({
address: entry.address16,
incomingCost: linkCost,
outgoingCost: linkCost,
});
}
catch {
/* ignore */
}
}
}
await this.sendLinkStatus(links);
this.#linkStatusTimeout?.refresh();
}
/**
* 05-3474-23 #3.6.3.5.2 (Many-to-One Route Discovery)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Issues ROUTE_REQUEST with Many-to-One flag when concentrator timer elapses
* - ✅ Enforces minimum spacing (CONFIG_NWK_CONCENTRATOR_MIN_TIME) to prevent flooding per spec guidance
* - ✅ Uses WITH_SOURCE_ROUTING mode to advertise concentrator capability
* DEVICE SCOPE: Coordinator, routers (N/A)
*/
async sendPeriodicManyToOneRouteRequest() {
if (Date.now() > this.#lastMTORRTime + CONFIG_NWK_CONCENTRATOR_MIN_TIME) {
await this.sendRouteReq(1 /* ZigbeeNWKManyToOne.WITH_SOURCE_ROUTING */, 65532 /* ZigbeeConsts.BCAST_DEFAULT */);
this.#manyToOneRouteRequestTimeout?.refresh();
this.#lastMTORRTime = Date.now();
}
}
/**
* Finds the best source route to the destination.
* Implements route aging, failure tracking, and intelligent route selection.
* Entries with expired routes or too many failures will be purged.
* Bails early if destination16 is broadcast.
* Throws if both 16/64 are undefined or if destination is unknown (not in device table).
* Throws if no route and device is not neighbor.
*
* SPEC COMPLIANCE NOTES (05-3474-23 #3.6.3):
* - ✅ Returns early for broadcast addresses (no routing needed)
* - ✅ Validates destination is known in device table
* - ✅ Returns undefined arrays for direct communication (neighbor devices)
* - ⚠️ ROUTE AGING: Implements custom aging mechanism
* - CONFIG_NWK_ROUTE_EXPIRY_TIME: 300000ms (5 minutes)
* - CONFIG_NWK_ROUTE_STALENESS_TIME: 120000ms (2 minutes)
* - These values are implementation-specific, not from spec
* - ✅ Route failure tracking with blacklisting:
* - CONFIG_NWK_ROUTE_MAX_FAILURES: 3 consecutive failures
* - Marks routes as unusable after threshold ✅
* - ⚠️ MULTI-CRITERIA ROUTE SELECTION:
* - Path cost (hop count) ✅
* - Staleness penalty (route age) ✅
* - Failure penalty (consecutive failures) ✅
* - Recency bonus (recently used routes) ✅
* - This is more sophisticated than spec requires
* - ✅ Checks MAC NO_ACK tracking for relay validation
* - Filters out routes with unreliable relays ✅
* - ✅ Triggers many-to-one route request when no valid routes
* - Uses setImmediate for non-blocking trigger ✅
* - ⚠️ SPEC DEVIATION: Route table per spec should be:
* - Destination address
* - Status (active, discovery underway, validation underway, inactive)
* - Next hop address
* - Source route subframe (if source routing)
* Current implementation uses array of SourceRouteTableEntry per destination
* This allows multiple routes per destination (more flexible)
* - ⚠️ ROUTE DISCOVERY: Triggers MTORR when needed
* - Spec #3.6.3.5: Route discovery should be used
* - Implementation uses many-to-one routing (concentrator) ✅
* - This is appropriate for coordinator as concentrator
*
* IMPORTANT: This is a critical performance path - called for every outgoing frame
* DEVICE SCOPE: Coordinator, routers (N/A)
*
* @param destination16
* @param destination64
* @returns
* - request invalid (e.g. broadcast destination): [undefined, undefined, undefined]
* - request valid and source route unavailable (unknown device or neighbor): [undefined, undefined, undefined]
* - request valid and source route available and >=1 relay: [last index in relayAddresses, list of relay addresses, cost of the path]
* - request valid and source route available and 0 relay: [undefined, undefined, cost of the path]
*/
findBestSourceRoute(destination16, destination64, ignoreStale = false) {
if (destination16 !== undefined && destination16 >= 65528 /* ZigbeeConsts.BCAST_MIN */) {
return [undefined, undefined, undefined];
}
if (destination16 === undefined) {
if (destination64 === undefined) {
throw new Error("Invalid parameters");
}
const device = this.#context.deviceTable.get(destination64);
if (device === undefined) {
throw new Error("Unknown destination");
}
destination16 = device.address16;
}
else if (!this.#context.address16ToAddress64.has(destination16)) {
throw new Error("Unknown destination");
}
const sourceRouteEntries = this.#context.sourceRouteTable.get(destination16);
if (sourceRouteEntries === undefined || sourceRouteEntries.length === 0) {
// cleanup
this.#context.sourceRouteTable.delete(destination16);
const device64 = destination64 ?? this.#context.address16ToAddress64.get(destination16);
const device = this.#context.deviceTable.get(device64);
if (device && !device.neighbor) {
// force immediate MTORR
logger_js_1.logger.warning(`No known route to ${destination16}:${destination64}, forcing discovery`, NS);
setImmediate(this.sendPeriodicManyToOneRouteRequest.bind(this));
// will send direct as "last resort"
}
return [undefined, undefined, undefined];
}
const now = Date.now();
const validEntries = [];
// filter out expired and blacklisted routes
for (const entry of sourceRouteEntries) {
if (!ignoreStale) {
const age = now - entry.lastUpdated;
// remove expired routes
if (age > CONFIG_NWK_ROUTE_EXPIRY_TIME) {
logger_js_1.logger.debug(() => `Route to ${destination16}:${destination64} expired (age=${age}ms)`, NS);
continue;
}
}
// remove blacklisted routes (too many consecutive failures)
if (entry.failureCount >= CONFIG_NWK_ROUTE_MAX_FAILURES) {
logger_js_1.logger.debug(() => `Route to ${destination16}:${destination64} blacklisted (failures=${entry.failureCount})`, NS);
continue;
}
if (entry.relayAddresses.length > CONFIG_NWK_MAX_SOURCE_ROUTE) {
// ignore if too many hops
continue;
}
// check if any relay has too many NO_ACK
let relayFailed = false;
for (const relay of entry.relayAddresses) {
const macNoACKs = this.#context.macNoACKs.get(relay);
if (macNoACKs !== undefined && macNoACKs >= CONFIG_NWK_CONCENTRATOR_DELIVERY_FAILURE_THRESHOLD) {
logger_js_1.logger.debug(() => `Route to ${destination16}:${destination64} via relay ${relay} has too many NO_ACKs (${macNoACKs})`, NS);
relayFailed = true;
break;
}
}
if (relayFailed) {
continue;
}
validEntries.push(entry);
}
// update the source route table
if (validEntries.length === 0) {
this.#context.sourceRouteTable.delete(destination16);
const device64 = destination64 ?? this.#context.address16ToAddress64.get(destination16);
const device = this.#context.deviceTable.get(device64);
if (device && !device.neighbor) {
logger_js_1.logger.warning(`All routes to ${destination16}:${destination64} invalid, forcing discovery`, NS);
setImmediate(this.sendPeriodicManyToOneRouteRequest.bind(this));
}
return [undefined, undefined, undefined];
}
if (validEntries.length !== sourceRouteEntries.length) {
this.#context.sourceRouteTable.set(destination16, validEntries);
}
// sort routes by composite score: path cost + staleness penalty + failure penalty + recency bonus
validEntries.sort((a, b) => {
const ageA = now - a.lastUpdated;
const ageB = now - b.lastUpdated;
// add staleness penalty (0-2 points based on age)
const stalenessPenaltyA = ageA > CONFIG_NWK_ROUTE_STALENESS_TIME ? Math.min(2, (ageA - CONFIG_NWK_ROUTE_STALENESS_TIME) / CONFIG_NWK_ROUTE_STALENESS_TIME) : 0;
const stalenessPenaltyB = ageB > CONFIG_NWK_ROUTE_STALENESS_TIME ? Math.min(2, (ageB - CONFIG_NWK_ROUTE_STALENESS_TIME) / CONFIG_NWK_ROUTE_STALENESS_TIME) : 0;
// add failure penalty (1 point per failure)
const failurePenaltyA = a.failureCount;
const failurePenaltyB = b.failureCount;
// add recency bonus (prefer recently used routes)
const recencyBonusA = a.lastUsed && now - a.lastUsed < 30000 ? -1 : 0;
const recencyBonusB = b.lastUsed && now - b.lastUsed < 30000 ? -1 : 0;
const scoreA = a.pathCost + stalenessPenaltyA + failurePenaltyA + recencyBonusA;
const scoreB = b.pathCost + stalenessPenaltyB + failurePenaltyB + recencyBonusB;
return scoreA - scoreB;
});
const bestEntry = validEntries[0];
if (bestEntry.relayAddresses.length === 0) {
// direct route (cost only, no relays)
return [undefined, undefined, bestEntry.pathCost];
}
return [bestEntry.relayAddresses.length - 1, bestEntry.relayAddresses, bestEntry.pathCost];
}
/**
* 05-3474-23 #3.6.3.3
*
* Mark a route as successfully used
*
* SPEC COMPLIANCE NOTES:
* - ✅ Resets failure counter and updates last-used timestamp after successful forwarding
* - ✅ Operates on currently selected best route entry to keep metrics coherent
* - ⚠️ Multi-entry route table means only first entry updated; others remain untouched
* DEVICE SCOPE: Coordinator, routers (N/A)
*
* @param destination16 Network address of the destination
*/
markRouteSuccess(destination16) {
const entries = this.#context.sourceRouteTable.get(destination16);
if (entries && entries.length > 0) {
const entry = entries[0]; // mark the currently-selected best route
entry.lastUsed = Date.now();
entry.failureCount = 0; // reset failure count on success
}
}
/**
* 05-3474-23 #3.6.3.3
*
* Mark a route as failed and handle route repair if needed.
* Consolidates failure tracking and MTORR triggering per Zigbee spec.
*
* SPEC COMPLIANCE NOTES:
* - ✅ Increments failure counter and triggers Many-to-One route discovery after threshold
* - ✅ Purges routes that rely on failed relay as required for repair
* - ✅ Supports explicit repair trigger (e.g., NWK_STATUS link failure)
* DEVICE SCOPE: Coordinator, routers (N/A)
*
* @param destination16 Network address of the destination
* @param triggerRepair If true, will purge routes using this destination as relay and trigger MTORR
*/
markRouteFailure(destination16, triggerRepair = false) {
const entries = this.#context.sourceRouteTable.get(destination16);
if (entries && entries.length > 0) {
const entry = entries[0]; // mark the currently-selected best route
entry.failureCount += 1;
logger_js_1.logger.debug(() => `Route to ${destination16} failed (failureCount=${entry.failureCount})`, NS);
// if blacklisted or explicit repair requested, purge and trigger MTORR
if (triggerRepair || entry.failureCount >= CONFIG_NWK_ROUTE_MAX_FAILURES) {
logger_js_1.logger.warning(`Route to ${destination16} ${triggerRepair ? "requires repair" : `blacklisted after ${entry.failureCount} failures`}, purging related routes and forcing discovery`, NS);
// purge all routes using this destination as a relay
for (const [addr16, routeEntries] of this.#context.sourceRouteTable) {
const filteredEntries = routeEntries.filter((e) => !e.relayAddresses.includes(destination16));
if (filteredEntries.length === 0) {
this.#context.sourceRouteTable.delete(addr16);
}
else if (filteredEntries.length !== routeEntries.length) {
this.#context.sourceRouteTable.set(addr16, filteredEntries);
}
}
// remove direct routes to the target as well
this.#context.sourceRouteTable.delete(destination16);
// trigger immediate route discovery
setImmediate(this.sendPeriodicManyToOneRouteRequest.bind(this));
}
}
}
/**
* 05-3474-23 #3.6.3.3 (Source routing tables)
*
* Create a new source route table entry
*
* SPEC COMPLIANCE NOTES:
* - ✅ Initializes relay list, path cost, and age information for source route maintenance
* - ✅ Resets failure counters and last-used metadata per new measurement
* - ⚠️ Implementation stores multiple route entries per destination (spec defines single entry with status)
* - Provides richer path selection but diverges from formal table layout
* DEVICE SCOPE: Coordinator, routers (N/A)
*/
/* @__INLINE__ */
createSourceRouteEntry(relayAddresses, pathCost) {
return {
relayAddresses,
pathCost,
lastUpdated: Date.now(),
failureCount: 0,
lastUsed: undefined,
};
}
/**
* 05-3474-23 #3.6.3.3
*
* Check if a source route already exists in the table
*
* SPEC COMPLIANCE NOTES:
* - ✅ Compares relay hop list and cost to detect duplicate paths before insertion
* - ✅ Accepts optional pre-fetched entry array to avoid redundant map lookups
* - ⚠️ Formally spec route table holds single entry per destination; this helper assumes multi-entry model
* DEVICE SCOPE: Coordinator, routers (N/A)
*/
hasSourceRoute(address16, newEntry, existingEntries) {
if (!existingEntries) {
existingEntries = this.#context.sourceRouteTable.get(address16);
if (!existingEntries) {
return false;
}
}
for (const existingEntry of existingEntries) {
if (newEntry.pathCost === existingEntry.pathCost && newEntry.relayAddresses.length === existingEntry.relayAddresses.length) {
let matching = true;
for (let i = 0; i < newEntry.relayAddresses.length; i++) {
if (newEntry.relayAddresses[i] !== existingEntry.relayAddresses[i]) {
matching = false;
break;
}
}
if (matching) {
return true;
}
}
}
return false;
}
// #endregion
// #region Commands
/**
* 05-3474-23 #3.4 (NWK command frames)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Prepends Zigbee NWK header and optional security per caller (spec Table 3-5)
* - ✅ Applies source routing when available via findBestSourceRoute (spec #3.6.3.3)
* - ✅ Maps NWK destination to MAC destination with broadcast handling
* - ⚠️ Relies on caller to ensure command-specific payload validity (e.g., TLVs)
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*
* @param cmdId Command identifier (first byte of payload)
* @param finalPayload Fully encoded NWK command payload (including cmdId)
* @param nwkSecurity Whether to enable NWK security header
* @param nwkSource16 Source network address for header
* @param nwkDest16 Destination network address
* @param nwkDest64 Optional destination IEEE address (for concentrator routing)
* @param nwkRadius Initial radius/TTL
* @returns True if success sending (or indirect transmission)
*/
async sendCommand(cmdId, finalPayload, nwkSecurity, nwkSource16, nwkDest16, nwkDest64, nwkRadius) {
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 nwkSeqNum = this.nextSeqNum();
const macSeqNum = this.#macHandler.nextSeqNum();
let relayIndex;
let relayAddresses;
try {
[relayIndex, relayAddresses] = this.findBestSourceRoute(nwkDest16, nwkDest64);
}
catch (error) {
logger_js_1.logger.error(`=x=> NWK CMD[seqNum=(${nwkSeqNum}/${macSeqNum}) cmdId=${cmdId} nwkDst=${nwkDest16}:${nwkDest64}] ${error.message}`, NS);
return false;
}
const macDest16 = nwkDest16 < 65528 /* ZigbeeConsts.BCAST_MIN */ ? (relayAddresses?.[relayIndex] ?? nwkDest16) : 65535 /* ZigbeeMACConsts.BCAST_ADDR */;
logger_js_1.logger.debug(() => `===> NWK CMD[seqNum=(${nwkSeqNum}/${macSeqNum}) cmdId=${cmdId} macDst16=${macDest16} nwkSrc16=${nwkSource16} nwkDst=${nwkDest16}:${nwkDest64} nwkRad=${nwkRadius}]`, NS);
const source64 = nwkSource16 === 0 /* ZigbeeConsts.COORDINATOR_ADDRESS */ ? this.#context.netParams.eui64 : this.#context.address16ToAddress64.get(nwkSource16);
const nwkFrame = (0, zigbee_nwk_js_1.encodeZigbeeNWKFrame)({
frameControl: {
frameType: 1 /* ZigbeeNWKFrameType.CMD */,
protocolVersion: 2 /* ZigbeeNWKConsts.VERSION_2007 */,
discoverRoute: 0 /* ZigbeeNWKRouteDiscovery.SUPPRESS */,
multicast: false,
security: nwkSecurity,
sourceRoute: relayIndex !== undefined,
extendedDestination: nwkDest64 !== undefined,
extendedSource: source64 !== undefined,
endDeviceInitiator: false,
},
destination16: nwkDest16,
destination64: nwkDest64,
source16: nwkSource16,
source64,
radius: this.#context.decrementRadius(nwkRadius),
seqNum: nwkSeqNum,
relayIndex,
relayAddresses,
}, finalPayload, 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 #3.4 (NWK command processing)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Dispatches all mandatory NWK commands for coordinator role (ROUTE_REQ/REPLY, NWK_STATUS, LEAVE, LINK_STATUS, etc.)
* - ✅ Maintains offset propagation between handlers to consume payload sequentially
* - ✅ Logs unsupported command IDs per spec recommendation to ignore silently (kept as warning for diagnostics)
* - ⚠️ Commissioning, Link Power Delta, ED Timeout handling partially implemented (documented TODOs)
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*/
async processCommand(data, macHeader, nwkHeader) {
let offset = 0;
const cmdId = data.readUInt8(offset);
offset += 1;
switch (cmdId) {
case 1 /* ZigbeeNWKCommandId.ROUTE_REQ */: {
offset = await this.processRouteReq(data, offset, macHeader, nwkHeader);
break;
}
case 2 /* ZigbeeNWKCommandId.ROUTE_REPLY */: {
offset = this.processRouteReply(data, offset, macHeader, nwkHeader);
break;
}
case 3 /* ZigbeeNWKCommandId.NWK_STATUS */: {
offset = await this.processStatus(data, offset, macHeader, nwkHeader);
break;
}
case 4 /* ZigbeeNWKCommandId.LEAVE */: {
offset = await this.processLeave(data, offset, macHeader, nwkHeader);
break;
}
case 5 /* ZigbeeNWKCommandId.ROUTE_RECORD */: {
offset = this.processRouteRecord(data, offset, macHeader, nwkHeader);
break;
}
case 6 /* ZigbeeNWKCommandId.REJOIN_REQ */: {
offset = await this.processRejoinReq(data, offset, macHeader, nwkHeader);
break;
}
case 7 /* ZigbeeNWKCommandId.REJOIN_RESP */: {
offset = this.processRejoinResp(data, offset, macHeader, nwkHeader);
break;
}
case 8 /* ZigbeeNWKCommandId.LINK_STATUS */: {
offset = this.processLinkStatus(data, offset, macHeader, nwkHeader);
break;
}
case 9 /* ZigbeeNWKCommandId.NWK_REPORT */: {
offset = this.processReport(data, offset, macHeader, nwkHeader);
break;
}
case 10 /* ZigbeeNWKCommandId.NWK_UPDATE */: {
offset = this.processUpdate(data, offset, macHeader, nwkHeader);
break;
}
case 11 /* ZigbeeNWKCommandId.ED_TIMEOUT_REQUEST */: {
offset = await this.processEdTimeoutRequest(data, offset, macHeader, nwkHeader);
break;
}
case 12 /* ZigbeeNWKCommandId.ED_TIMEOUT_RESPONSE */: {
offset = this.processEdTimeoutResponse(data, offset, macHeader, nwkHeader);
break;
}
case 13 /* ZigbeeNWKCommandId.LINK_PWR_DELTA */: {
offset = this.processLinkPwrDelta(data, offset, macHeader, nwkHeader);
break;
}
case 14 /* ZigbeeNWKCommandId.COMMISSIONING_REQUEST */: {
offset = await this.processCommissioningRequest(data, offset, macHeader, nwkHeader);
break;
}
case 15 /* ZigbeeNWKCommandId.COMMISSIONING_RESPONSE */: {
offset = this.processCommissioningResponse(data, offset, macHeader, nwkHeader);
break;
}
default: {
logger_js_1.logger.error(`<=x= NWK 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(() => `<=== NWK CMD contained more data: ${data.toString('hex')}`, NS);
// }
}
/**
* 05-3474-23 #3.4.1 (Route Request)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Decodes options, destination, and many-to-one fields per Table 3-12
* - ✅ Sends ROUTE_REPLY when coordinator is destination (spec #3.6.3.5.2 requirement for concentrators)
* - ✅ Preserves destination64 when provided to maintain IEEE correlation
* - ⚠️ Path cost not incremented (acceptable for terminal node)
* - ⚠️ Route discovery table not implemented (coordinator does not forward requests)
* DEVICE SCOPE: Coordinator, routers (N/A)
*
* @param data Command data
* @param offset Current offset in data
* @param macHeader MAC header
* @param nwkHeader NWK header
* @returns New offset after processing
*/
async processRouteReq(data, offset, macHeader, nwkHeader) {
const options = data.readUInt8(offset);
offset += 1;
const manyToOne = (options & 24 /* ZigbeeNWKConsts.CMD_ROUTE_OPTION_MANY_MASK */) >> 3; // ZigbeeNWKManyToOne
const id = data.readUInt8(offset);
offset += 1;
const destination16 = data.readUInt16LE(offset);
offset += 2;
const pathCost = data.readUInt8(offset);
offset += 1;
let destination64;
if (options & 32 /* ZigbeeNWKConsts.CMD_ROUTE_OPTION_DEST_EXT */) {
destination64 = data.readBigUInt64LE(offset);
offset += 8;
}
logger_js_1.logger.debug(() => `<=== NWK ROUTE_REQ[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} id=${id} dst=${destination16}:${destination64} pCost=${pathCost} mto=${manyToOne}]`, NS);
if (destination16 < 65528 /* ZigbeeConsts.BCAST_MIN */) {
await this.sendRouteReply(macHeader.destination16, nwkHeader.radius, id, nwkHeader.source16, destination16, nwkHeader.source64 ?? this.#context.address16ToAddress64.get(nwkHeader.source16), destination64);
}
return offset;
}
/**
* 05-3474-23 #3.4.1 (Route Request)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Encodes options bits for many-to-one and DEST_EXT addressing
* - ✅ Uses modulo-256 route request identifier (nextRouteRequestId)
* - ✅ Broadcasts discovery (dest=BCAST_DEFAULT) when acting as concentrator
* - ⚠️ TLV payload not supported (optional R23 extension)
* DEVICE SCOPE: Coordinator, routers (N/A)
*
* @param manyToOne
* @param destination16 intended destination of the route request command frame
* @param destination64 SHOULD always be added if it is known
* @returns
*/
async sendRouteReq(manyToOne, destination16, destination64) {
logger_js_1.logger.debug(() => `===> NWK ROUTE_REQ[mto=${manyToOne} dst=${destination16}:${destination64}]`, NS);
const hasDestination64 = destination64 !== undefined;
const options = ((manyToOne << 3) & 24 /* ZigbeeNWKConsts.CMD_ROUTE_OPTION_MANY_MASK */) |
(((hasDestination64 ? 1 : 0) << 5) & 32 /* ZigbeeNWKConsts.CMD_ROUTE_OPTION_DEST_EXT */);
const finalPayload = Buffer.alloc(1 + 1 + 1 + 2 + 1 + (hasDestination64 ? 8 : 0));
let offset = 0;
offset = finalPayload.writeUInt8(1 /* ZigbeeNWKCommandId.ROUTE_REQ */, offset);
offset = finalPayload.writeUInt8(options, offset);
offset = finalPayload.writeUInt8(this.nextRouteRequestId(), offset);
offset = finalPayload.writeUInt16LE(destination16, offset);
offset = finalPayload.writeUInt8(0, offset); // initial path cost
if (hasDestination64) {
offset = finalPayload.writeBigUInt64LE(destination64, offset);
}
return await this.sendCommand(1 /* ZigbeeNWKCommandId.ROUTE_REQ */, finalPayload, true, // nwkSecurity
0 /* ZigbeeConsts.COORDINATOR_ADDRESS */, // nwkSource16
65532 /* ZigbeeConsts.BCAST_DEFAULT */, // nwkDest16
undefined, // nwkDest64
CONFIG_NWK_CONCENTRATOR_RADIUS);
}
/**
* 05-3474-23 #3.4.2 (Route Reply)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Decodes originator/responder addresses (short and extended) per options mask
* - ✅ Reconstructs relay path including MAC next hop when coordinator originates discovery
* - ✅ Normalizes zero path cost to hop-derived value to satisfy spec requirement (>0)
* - ⚠️ TLVs and status-field failure indicators remain TODO
* DEVICE SCOPE: Coordinator, routers (N/A)
*/
processRouteReply(data, offset, macHeader, nwkHeader) {
const options = data.readUInt8(offset);
offset += 1;
const id = data.readUInt8(offset);
offset += 1;
const originator16 = data.readUInt16LE(offset);
offset += 2;
const responder16 = data.readUInt16LE(offset);
offset += 2;
const pathCost = data.readUInt8(offset);
offset += 1;
let originator64;
let responder64;
if (options & 16 /* ZigbeeNWKConsts.CMD_ROUTE_OPTION_ORIG_EXT */) {
originator64 = data.readBigUInt64LE(offset);
offset += 8;
}
if (options & 32 /* ZigbeeNWKConsts.CMD_ROUTE_OPTION_RESP_EXT */) {
responder64 = data.readBigUInt64LE(offset);
offset += 8;
}
// TODO
// const [tlvs, tlvsOutOffset] = decodeZigbeeNWKTLVs(data, offset);
logger_js_1.logger.debug(() => `<=== NWK ROUTE_REPLY[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} id=${id} orig=${originator16}:${originator64} rsp=${responder16}:${responder64} pCost=${pathCost}]`, NS);
// Cache source route to responder when coordinator initiated discovery
if (originator16 === 0 /* ZigbeeConsts.COORDINATOR_ADDRESS */) {
const nextHopCandidates = nwkHeader.relayAddresses !== undefined ? [...nwkHeader.relayAddresses] : [];
const macNextHop = macHeader.source16 ?? (macHeader.source64 !== undefined ? this.#context.deviceTable.get(macHeader.source64)?.address16 : undefined);
if (macNextHop !== undefined && macNextHop !== responder16) {
if (nextHopCandidates.length === 0 || nextHopCandidates[nextHopCandidates.length - 1] !== macNextHop) {
nextHopCandidates.push(macNextHop);
}
}
const routeEntry = this.createSourceRouteEntry(nextHopCandidates, pathCost === 0 ? nextHopCandidates.length + 1 : pathCost);
const existingEntries = this.#context.sourceRouteTable.get(responder16);
if (existingEntries === undefined) {
this.#context.sourceRouteTable.set(responder16, [routeEntry]);
}
else {
const existingIndex = existingEntries.findIndex((entry) => entry.relayAddresses.length === routeEntry.relayAddresses.length &&
entry.relayAddresses.every((relay, idx) => relay === routeEntry.relayAddresses[idx]));
if (existingIndex !== -1) {
const existingEntry = existingEntries[existingIndex];
existingEntry.pathCost = routeEntry.pathCost;
existingEntry.lastUpdated = routeEntry.lastUpdated;
existingEntry.failureCount = 0;
}
else if (!this.hasSourceRoute(responder16, routeEntry, existingEntries)) {
// TODO: do we want this here?
existingEntries.push(routeEntry);
}
}
this.markRouteSuccess(responder16);
}
return offset;
}
/**
* 05-3474-23 #3.4.2 / #3.6.4.5.2 (Route Reply)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Encodes IEEE address presence bits and includes optional fields
* - ✅ Sets path cost to 1 hop when coordinator responds directly
* - ✅ Unicasts reply via first hop recorded in request MAC header
* - ⚠️ TLV payload not encoded (optional R23 extension)
* DEVICE SCOPE: Coordinator, routers (N/A)
*
* @param requestDest1stHop16 SHALL be set to the network address of the first hop in the path back to the originator of the corresponding route request command frame
* @param requestRadius
* @param requestId 8-bit sequence number of the route request to which this frame is a reply
* @param originator16 SHALL contain the 16-bit network address of the originator of the route request command frame to which this frame is a reply
* @param responder16 SHALL always be the same as the value in the destination address field of the corresponding route request command frame
* @param originator64 SHALL be 8 octets in length and SHALL contain the 64-bit address of the originator of the route request command frame to which this frame is a reply.
* This field SHALL only be present if the originator IEEE address sub-field of the command options field has a value of 1.
* @param responder64 SHALL be 8 octets in length and SHALL contain the 64-bit address of the destination of the route request command frame to which this frame is a reply.
* This field SHALL only be present if the responder IEEE address sub-field of the command options field has a value of 1.
* @returns
*/
async sendRouteReply(requestDest1stHop16, requestRadius, requestId, originator16, responder16, originator64, responder64) {
logger_js_1.logger.debug(() => `===> NWK ROUTE_REPLY[reqDst1stHop16=${requestDest1stHop16} reqRad=${requestRadius} reqId=${requestId} orig=${originator16}:${originator64} rsp=${responder16}:${responder64}]`, NS);
const hasOriginator64 = originator64 !== undefined;
const hasResponder64 = responder64 !== undefined;
const options = (((hasOriginator64 ? 1 : 0) << 4) & 16 /* ZigbeeNWKConsts.CMD_ROUTE_OPTION_ORIG_EXT */) |
(((hasResponder64 ? 1 : 0) << 5) & 32 /* ZigbeeNWKConsts.CMD_ROUTE_OPTION_RESP_EXT */);
const finalPayload = Buffer.alloc(1 + 1 + 1 + 2 + 2 + 1 + (hasOriginator64 ? 8 : 0) + (hasResponder64 ? 8 : 0));
let offset = 0;
offset = finalPayload.writeUInt8(2 /* ZigbeeNWKCommandId.ROUTE_REPLY */, offset);
offset = finalPayload.writeUInt8(options, offset);
offset = finalPayload.writeUInt8(requestId, offset);
offset = finalPayload.writeUInt16LE(originator16, offset);
offset = finalPayload.writeUInt16LE(responder16, offset);
offset = finalPayload.writeUInt8(1, offset); // path cost for direct response
if (hasOriginator64) {
offset = finalPayload.writeBigUInt64LE(originator64, offset);
}
if (hasResponder64) {
offset = finalPayload.writeBigUInt64LE(responder64, offset);
}
// TODO
// const [tlvs, tlvsOutOffset] = encodeZigbeeNWKTLVs();
return await this.sendCommand(2 /* ZigbeeNWKCommandId.ROUTE_REPLY */, finalPayload, true, // nwkSecurity
0 /* ZigbeeConsts.COORDINATOR_ADDRESS */, // nwkSource16
requestDest1stHop16, // nwkDest16
this.#context.address16ToAddress64.get(requestDest1stHop16), // nwkDest64
requestRadius);
}
/**
* 05-3474-23 #3.4.3
*
* SPEC COMPLIANCE:
* - ✅ Correctly decodes status code
* - ✅ Handles destination16 parameter for routing failures
* - ✅ Marks route as failed and schedules MTORR recovery
* - ✅ Logs network status issues for diagnostics
* - ❌ NOT IMPLEMENTED: TLV processing (R23)
* - ✅ Issues REJOIN_RESP with address-conflict status to prompt device reassignment
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*
* IMPACT: Receives status but minimal action beyond route marking
*/
async processStatus(data, offset, macHeader, nwkHeader) {
const status = data.readUInt8(offset);
offset += 1;
// target SHALL be present if, and only if, frame is being sent in response to a routing failure or a network address conflict
let target16;
if (status === zigbee_nwk_js_1.ZigbeeNWKStatus.LEGACY_NO_ROUTE_AVAILABLE ||
status === zigbee_nwk_js_1.ZigbeeNWKStatus.LEGACY_LINK_FAILURE ||
status === zigbee_nwk_js_1.ZigbeeNWKStatus.LINK_FAILURE ||
status === zigbee_nwk_js_1.ZigbeeNWKStatus.SOURCE_ROUTE_FAILURE ||
status === zigbee_nwk_js_1.ZigbeeNWKStatus.MANY_TO_ONE_ROUTE_FAILURE) {
// In case of a routing failure, it SHALL contain the destination address from the data frame that encountered the failure
target16 = data.readUInt16LE(offset);
offset += 2;
// mark route as failed with repair - this will purge routes using target as relay and trigger MTORR once
this.markRouteFailure(target16, true);
}
else if (status === zigbee_nwk_js_1.ZigbeeNWKStatus.ADDRESS_CONFLICT) {
// In case of an address conflict, it SHALL contain the offending network address.
target16 = data.readUInt16LE(offset);
offset += 2;
if (target16 !== 0 /* ZigbeeConsts.COORDINATOR_ADDRESS */) {
const device64 = this.#context.address16ToAddress64.get(target16);
if (device64 !== undefined) {
const newAddress16 = this.#context.assignNetworkAddress();
// TODO: is this correct?
await this.sendRejoinResp(target16, newAddress16, 240 /* ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT */);
}
else {
logger_js_1.logger.warning(() => `NWK address conflict reported for unknown short address ${target16}`, NS);
}
}
}
// TODO
// const [tlvs, tlvsOutOffset] = decodeZigbeeNWKTLVs(data, offset);
logger_js_1.logger.debug(() => `<=== NWK NWK_STATUS[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} status=${zigbee_nwk_js_1.ZigbeeNWKStatus[status]} dst16=${target16}]`, NS);
return offset;
}
/**
* 05-3474-23 #3.4.3
*
* SPEC COMPLIANCE:
* - ✅ Sends to appropriate destination (broadcast or unicast)
* - ✅ Includes error codes (NO_ROUTE_AVAILABLE, LINK_FAILURE, etc.)
* - ✅ No security applied (per spec)
* - ✅ Optional destination16 for routing failures/address conflicts
* DEVICE SCOPE: Coordinator, routers (N/A)
*
* @param requestSource16
* @param status
* @param destination Destination address (only if status is LINK_FAILURE or ADDRESS_CONFLICT)
* - in case of a routing failure, it SHALL contain the destination address from the data frame that encountered the failure
* - in case of an address conflict, it SHALL contain the offending network address.
* @returns
*/
async sendStatus(requestSource16, status, destination) {
logger_js_1.logger.debug(() => `===> NWK NWK_STATUS[reqSrc16=${requestSource16} status=${status} dst16=${destination}]`, NS);
let finalPayload;
if (status === zigbee_nwk_js_1.ZigbeeNWKStatus.LINK_FAILURE || status === zigbee_nwk_js_1.ZigbeeNWKStatus.ADDRESS_CONFLICT) {
finalPayload = Buffer.from([3 /* ZigbeeNWKCommandId.NWK_STATUS */, status, destination & 0xff, (destination >> 8) & 0xff]);
}
else {
finalPayload = Buffer.from([3 /* ZigbeeNWKCommandId.NWK_STATUS */, status]);
}
// TODO
// const [tlvs, tlvsOutOffset] = encodeZigbeeNWKTLVs();
return await this.sendCommand(3 /* ZigbeeNWKCommandId.NWK_STATUS */, finalPayload, true, // nwkSecurity
0 /* ZigbeeConsts.COORDINATOR_ADDRESS */, // nwkSource16
requestSource16, // nwkDest16
this.#context.address16ToAddress64.get(requestSource16), // nwkDest64
exports.CONFIG_NWK_MAX_HOPS);
}
/**
* 05-3474-23 #3.4.4 (Leave command)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Parses removeChildren/request/rejoin flags from options byte (Table 3-16)
* - ✅ Invokes disassociate when device signals final leave (request=false & rejoin=false)
* - ⚠️ removeChildren flag purposely ignored (deprecated)
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*/
async processLeave(data, offset, macHeader, nwkHeader) {
const options = data.readUInt8(offset);
offset += 1;
const removeChildren = Boolean(options & 128 /* ZigbeeNWKConsts.CMD_LEAVE_OPTION_REMOVE_CHILDREN */);
const request = Boolean(options & 64 /* ZigbeeNWKConsts.CMD_LEAVE_OPTION_REQUEST */);
const rejoin = Boolean(options & 32 /* ZigbeeNWKConsts.CMD_LEAVE_OPTION_REJOIN */);
logger_js_1.logger.debug(() => `<=== NWK LEAVE[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} remChildren=${removeChildren} req=${request} rejoin=${rejoin}]`, NS);
if (!rejoin && !request) {
await this.#context.disassociate(nwkHeader.source16, nwkHeader.source64);
}
return offset;
}
/**
* 05-3474-23 #3.4.4 (Leave command)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Sets request bit (bit6) and optional rejoin bit based on caller input
* - ✅ Forces removeChildren=0 to avoid unintended network disruption (spec allows but not typical for TC)
* - ✅ Applies NWK security and unicasts to destination per coordinator requirements
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*
*