UNPKG

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
"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) * *