zigbee-on-host
Version:
Zigbee stack designed to run on a host and communicate with a radio co-processor (RCP)
1,001 lines • 55 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.StackContext = exports.END_DEVICE_TIMEOUT_TABLE_MS = exports.NetworkKeyUpdateMethod = exports.ApplicationKeyRequestPolicy = exports.TrustCenterKeyRequestPolicy = exports.InstallCodePolicy = void 0;
const promises_1 = require("node:fs/promises");
const logger_js_1 = require("../utils/logger.js");
const mac_js_1 = require("../zigbee/mac.js");
const zigbee_js_1 = require("../zigbee/zigbee.js");
const descriptors_js_1 = require("./descriptors.js");
const nwk_handler_js_1 = require("./nwk-handler.js");
const save_serializer_js_1 = require("./save-serializer.js");
const NS = "stack-context";
var InstallCodePolicy;
(function (InstallCodePolicy) {
/** Do not support Install Codes */
InstallCodePolicy[InstallCodePolicy["NOT_SUPPORTED"] = 0] = "NOT_SUPPORTED";
/** Support but do not require use of Install Codes or preset passphrases */
InstallCodePolicy[InstallCodePolicy["NOT_REQUIRED"] = 1] = "NOT_REQUIRED";
/** Require the use of Install Codes by joining devices or preset Passphrases */
InstallCodePolicy[InstallCodePolicy["REQUIRED"] = 2] = "REQUIRED";
})(InstallCodePolicy || (exports.InstallCodePolicy = InstallCodePolicy = {}));
var TrustCenterKeyRequestPolicy;
(function (TrustCenterKeyRequestPolicy) {
TrustCenterKeyRequestPolicy[TrustCenterKeyRequestPolicy["DISALLOWED"] = 0] = "DISALLOWED";
/** Any device MAY request */
TrustCenterKeyRequestPolicy[TrustCenterKeyRequestPolicy["ALLOWED"] = 1] = "ALLOWED";
/** Only devices in the apsDeviceKeyPairSet with a KeyAttribute value of PROVISIONAL_KEY MAY request. */
TrustCenterKeyRequestPolicy[TrustCenterKeyRequestPolicy["ONLY_PROVISIONAL"] = 2] = "ONLY_PROVISIONAL";
})(TrustCenterKeyRequestPolicy || (exports.TrustCenterKeyRequestPolicy = TrustCenterKeyRequestPolicy = {}));
var ApplicationKeyRequestPolicy;
(function (ApplicationKeyRequestPolicy) {
ApplicationKeyRequestPolicy[ApplicationKeyRequestPolicy["DISALLOWED"] = 0] = "DISALLOWED";
/** Any device MAY request an application link key with any device (except the Trust Center) */
ApplicationKeyRequestPolicy[ApplicationKeyRequestPolicy["ALLOWED"] = 1] = "ALLOWED";
/** Only those devices listed in applicationKeyRequestList MAY request and receive application link keys. */
ApplicationKeyRequestPolicy[ApplicationKeyRequestPolicy["ONLY_APPROVED"] = 2] = "ONLY_APPROVED";
})(ApplicationKeyRequestPolicy || (exports.ApplicationKeyRequestPolicy = ApplicationKeyRequestPolicy = {}));
var NetworkKeyUpdateMethod;
(function (NetworkKeyUpdateMethod) {
/** Broadcast using only network encryption */
NetworkKeyUpdateMethod[NetworkKeyUpdateMethod["BROADCAST"] = 0] = "BROADCAST";
/** Unicast using network encryption and APS encryption with a device’s link key. */
NetworkKeyUpdateMethod[NetworkKeyUpdateMethod["UNICAST"] = 1] = "UNICAST";
})(NetworkKeyUpdateMethod || (exports.NetworkKeyUpdateMethod = NetworkKeyUpdateMethod = {}));
/** Table 3-54 */
exports.END_DEVICE_TIMEOUT_TABLE_MS = [
10_000,
2 * 60 * 1000,
4 * 60 * 1000,
8 * 60 * 1000,
16 * 60 * 1000,
32 * 60 * 1000,
64 * 60 * 1000,
128 * 60 * 1000,
256 * 60 * 1000,
512 * 60 * 1000,
1024 * 60 * 1000,
2048 * 60 * 1000,
4096 * 60 * 1000,
8192 * 60 * 1000,
16_384 * 60 * 1000,
];
/** The time between state saving to disk. (msec) */
const CONFIG_SAVE_STATE_TIME = 60000;
/** Offset added to frame counter properties on save */
const CONFIG_SAVE_FRAME_COUNTER_JUMP_OFFSET = 1024;
/**
* Centralized shared state and counters for the Zigbee stack.
*
* This context holds all shared state between protocol layers including:
* - Network parameters
* - Device and routing tables
* - Frame counters (MAC, NWK, APS, ZDO)
* - Trust Center policies
* - RSSI/LQI ranges
*/
class StackContext {
#callbacks;
/** Master table of all known devices on the network (mapped by IEEE address) */
deviceTable = new Map();
/** Address lookup: 16-bit to 64-bit (synced with deviceTable) */
address16ToAddress64 = new Map();
/** Source routing table (mapped by 16-bit address) */
sourceRouteTable = new Map();
/** Application link keys stored for device pairs (ordered by IEEE address) */
appLinkKeyTable = new Map();
/** Install code metadata per device (mapped by IEEE address) */
installCodeTable = new Map();
/** Trust Center policies */
trustCenterPolicies = {
allowJoins: false,
installCode: InstallCodePolicy.NOT_REQUIRED,
allowRejoinsWithWellKnownKey: true,
allowTCKeyRequest: TrustCenterKeyRequestPolicy.ALLOWED,
networkKeyUpdatePeriod: 0, // disable
networkKeyUpdateMethod: NetworkKeyUpdateMethod.BROADCAST,
allowAppKeyRequest: ApplicationKeyRequestPolicy.DISALLOWED,
allowRemoteTCPolicyChange: false,
allowVirtualDevices: false,
};
/** Configuration attributes */
configAttributes = {
address: Buffer.alloc(0),
nodeDescriptor: Buffer.alloc(0),
powerDescriptor: Buffer.alloc(0),
simpleDescriptors: Buffer.alloc(0),
activeEndpoints: Buffer.alloc(0),
};
/** Count of MAC NO_ACK reported for each device (mapping by network address) */
macNoACKs = new Map();
/** Associations pending DATA_RQ from device (mapping by IEEE address) */
pendingAssociations = new Map();
/** Indirect transmission for devices with rxOnWhenIdle=false (mapping by IEEE address) */
indirectTransmissions = new Map();
#savePath;
#saveStateTimeout;
#loaded = false;
/** Network parameters */
netParams;
/** Pre-computed hash of default TC link key for VERIFY_KEY */
tcVerifyKeyHash = Buffer.alloc(0);
/** MAC association permit flag */
associationPermit = false;
//---- Trust Center (see 05-3474-23 #4.7.1)
#allowJoinTimeout;
#pendingNetworkKey;
#pendingNetworkKeySequenceNumber;
/** Minimum observed RSSI */
rssiMin = -100;
/** Maximum observed RSSI */
rssiMax = -25;
/** Minimum observed LQI */
lqiMin = 15;
/** Maximum observed LQI */
lqiMax = 250;
constructor(callbacks, savePath, netParams) {
this.#callbacks = callbacks;
this.#savePath = savePath;
this.netParams = netParams;
}
// #region Getters/Setters
get loaded() {
return this.#loaded;
}
// #endregion
/**
* 05-3474-23 #4.7.6 (Trust Center maintenance)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Schedules periodic state persistence while stack is running
* - ✅ Immediately saves state to ensure on-disk snapshot reflects startup values
* - ⚠️ Additional periodic tasks (key rotation, metrics) remain TODO
* DEVICE SCOPE: Trust Center
*/
async start() {
// TODO: periodic/delayed actions
this.#saveStateTimeout = setTimeout(this.savePeriodicState.bind(this), CONFIG_SAVE_STATE_TIME);
await this.savePeriodicState();
}
/**
* 05-3474-23 #4.7.6 (Trust Center maintenance)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Cancels pending timers and ensures join window closed on shutdown
* - ✅ Mirrors spec recommendation to revoke permit-join when TC inactive
* DEVICE SCOPE: Trust Center
*/
stop() {
clearTimeout(this.#saveStateTimeout);
this.#saveStateTimeout = undefined;
this.disallowJoins();
}
/**
* 05-3474-23 #4.7.6 (Trust Center maintenance)
*
* Remove the save file and clear tables (just in case)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Clears persistent storage and in-memory tables when performing factory reset
* - ⚠️ Caller must reinitialize descriptors/netParams afterwards per spec flow
* DEVICE SCOPE: Trust Center
*/
async clear() {
// remove `zoh.save`
await (0, promises_1.rm)(this.#savePath, { force: true });
this.deviceTable.clear();
this.address16ToAddress64.clear();
this.sourceRouteTable.clear();
this.appLinkKeyTable.clear();
this.installCodeTable.clear();
this.macNoACKs.clear();
this.pendingAssociations.clear();
this.indirectTransmissions.clear();
}
/**
* Get next Trust Center key frame counter.
* HOT PATH: Optimized counter increment
* @returns Incremented TC key frame counter (wraps at 0xffffffff)
*/
/* @__INLINE__ */
nextTCKeyFrameCounter() {
this.netParams.tcKeyFrameCounter = ((this.netParams.tcKeyFrameCounter + 1) & 0xffffffff) >>> 0;
return this.netParams.tcKeyFrameCounter;
}
/**
* Get next network key frame counter.
* HOT PATH: Optimized counter increment
* @returns Incremented network key frame counter (wraps at 0xffffffff)
*/
/* @__INLINE__ */
nextNWKKeyFrameCounter() {
this.netParams.networkKeyFrameCounter = ((this.netParams.networkKeyFrameCounter + 1) & 0xffffffff) >>> 0;
return this.netParams.networkKeyFrameCounter;
}
/**
* 05-3474-23 #4.4.11.2 (Network Key transport)
*
* Store a pending network key that will become active once a matching SWITCH_KEY is received.
*
* SPEC COMPLIANCE NOTES:
* - ✅ Stages pending network key and associated sequence number per TRANSPORT_KEY requirements
* - ✅ Normalizes sequence to 8-bit value to mirror Zigbee NWK field size
* - ✅ Copies key material to avoid caller mutations (spec mandates immutable staging)
* - ⚠️ Does not persist staged key to disk; relies on immediate SWITCH_KEY follow-up (acceptable for coordinator uptime)
* DEVICE SCOPE: Trust Center
*
* @param key Raw network key bytes (16 bytes)
* @param sequenceNumber Sequence number advertised for the pending key
*/
setPendingNetworkKey(key, sequenceNumber) {
this.#pendingNetworkKey = Buffer.from(key);
this.#pendingNetworkKeySequenceNumber = sequenceNumber & 0xff;
logger_js_1.logger.debug(() => `Staged pending network key seq=${this.#pendingNetworkKeySequenceNumber}`, NS);
}
markNetworkKeyTransported(address64) {
const device = this.deviceTable.get(address64);
if (device !== undefined) {
device.lastTransportedNetworkKeySeq = this.netParams.networkKeySequenceNumber;
}
}
/**
* 05-3474-23 #4.4.11.5 (Switch Key)
*
* Activate the staged network key if the sequence number matches.
* Resets frame counters and re-registers hashed keys for cryptographic operations.
*
* SPEC COMPLIANCE NOTES:
* - ✅ Activates staged key only when sequence matches SWITCH_KEY command
* - ✅ Resets NWK frame counter as mandated after key activation
* - ✅ Re-registers hashed keys for LINK/NWK/TRANSPORT/LOAD contexts to keep crypto in sync
* - ✅ Clears staging buffers to prevent reuse or leakage
* - ⚠️ Does not emit management notifications; assumes higher layer handles ANNCE broadcasts
* DEVICE SCOPE: Trust Center
*
* @param sequenceNumber Sequence number referenced by SWITCH_KEY command
* @returns true when activation succeeded, false when no matching pending key exists
*/
activatePendingNetworkKey(sequenceNumber) {
if (this.#pendingNetworkKey === undefined || this.#pendingNetworkKeySequenceNumber !== sequenceNumber) {
return false;
}
this.netParams.networkKey = Buffer.from(this.#pendingNetworkKey);
this.netParams.networkKeySequenceNumber = sequenceNumber;
this.netParams.networkKeyFrameCounter = 0;
this.#pendingNetworkKey = undefined;
this.#pendingNetworkKeySequenceNumber = undefined;
(0, zigbee_js_1.registerDefaultHashedKeys)((0, zigbee_js_1.makeKeyedHashByType)(0 /* ZigbeeKeyType.LINK */, this.netParams.tcKey), (0, zigbee_js_1.makeKeyedHashByType)(1 /* ZigbeeKeyType.NWK */, this.netParams.networkKey), (0, zigbee_js_1.makeKeyedHashByType)(2 /* ZigbeeKeyType.TRANSPORT */, this.netParams.tcKey), (0, zigbee_js_1.makeKeyedHashByType)(3 /* ZigbeeKeyType.LOAD */, this.netParams.tcKey));
logger_js_1.logger.debug(() => `Activated network key seq=${sequenceNumber}`, NS);
return true;
}
// private countDirectChildren(exclude64?: bigint): { childCount: number; routerCount: number } {
// let childCount = 0;
// let routerCount = 0;
// for (const [device64, entry] of this.deviceTable) {
// if (!entry.neighbor) {
// continue;
// }
// if (exclude64 !== undefined && device64 === exclude64) {
// continue;
// }
// childCount += 1;
// if (entry.capabilities?.deviceType === 1) {
// routerCount += 1;
// }
// }
// return { childCount, routerCount };
// }
/**
* 05-3474-23 #3.6.1.10 (Network address allocation)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Allocates short addresses within 0x0001-0xfff7 range, excluding coordinator and broadcast values
* - ✅ Ensures uniqueness against current `address16ToAddress64` map before assignment
* - ⚠️ Uses pseudo-random selection rather than deterministic increment (allowed by spec)
* - ⚠️ No persistence of last issued address; relies on state table to avoid collisions after reboot
* DEVICE SCOPE: Coordinator, routers (N/A)
*/
assignNetworkAddress() {
let newNetworkAddress = 0xffff;
let unique = false;
do {
// maximum exclusive, minimum inclusive
newNetworkAddress = Math.floor(Math.random() * (65528 /* ZigbeeConsts.BCAST_MIN */ - 0x0001) + 0x0001);
unique = this.address16ToAddress64.get(newNetworkAddress) === undefined;
} while (!unique);
return newNetworkAddress;
}
/**
* 05-3474-23 #3.6.1.11 / Table 3-54 (End Device Timeout)
*
* Update the stored end device timeout metadata for a device.
*
* SPEC COMPLIANCE NOTES:
* - ✅ Validates timeout index against Table 3-54 mapping (0-14)
* - ✅ Stores absolute expiration timestamp for NLME-ED-TIMEOUT enforcement
* - ✅ Persists last requested index to reuse during retransmission handling
* - ⚠️ Does not enforce parent capability bits; assumes MAC handler already vetted support
* - ⚠️ Lifetime not persisted to disk (cleared on restart per spec allowance)
* DEVICE SCOPE: Coordinator, routers (N/A)
*
* @param address64 IEEE address of the end device
* @param timeoutIndex Requested timeout index (0-14)
* @param now Optional timestamp override (for testing)
* @returns Updated timeout metadata or undefined if device/index invalid
*/
updateEndDeviceTimeout(address64, timeoutIndex, now = Date.now()) {
const timeoutMs = exports.END_DEVICE_TIMEOUT_TABLE_MS[timeoutIndex];
if (timeoutMs === undefined) {
return undefined;
}
const device = this.deviceTable.get(address64);
if (device === undefined) {
return undefined;
}
device.endDeviceTimeout = {
timeoutIndex,
timeoutMs,
lastUpdated: now,
expiresAt: now + timeoutMs,
};
return device.endDeviceTimeout;
}
/**
* 05-3474-23 #3.7.3 (NWK security) / IEEE 802.15.4-2015 #9.4.2
*
* Update and validate the incoming NWK security frame counter for a device.
*
* SPEC COMPLIANCE NOTES:
* - ✅ Rejects replayed NWK security frames when counter does not strictly increase
* - ✅ Handles counter wrap from 0xffffffff → 0 per Zigbee PRO requirement
* - ✅ Stores last accepted counter per IEEE address for subsequent validation
* - ⚠️ Devices without stored state (e.g., unknown IEEE) default to allowing frame (per spec recommendation)
* - ⚠️ Persistence across restarts not implemented (TODO noted)
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*
* @param address64
* @param frameCounter
* @returns false if the provided counter is a replay (<= stored value, excluding wrap).
*/
updateIncomingNWKFrameCounter(address64, frameCounter) {
if (address64 === undefined) {
return true;
}
const device = this.deviceTable.get(address64);
if (device === undefined) {
return true;
}
const previous = device.incomingNWKFrameCounter;
if (previous === undefined) {
device.incomingNWKFrameCounter = frameCounter;
return true;
}
if (previous === 0xffffffff && frameCounter === 0) {
device.incomingNWKFrameCounter = frameCounter;
return true;
}
if (frameCounter > previous) {
device.incomingNWKFrameCounter = frameCounter;
return true;
}
return false;
}
/**
* IEEE 802.15.4-2015 #10.2.1 (Link Quality Indication)
*
* Apply logistic curve on standard mapping to LQI range [0..255]
*
* - Silabs EFR32: the RSSI range of [-100..-36] is mapped to an LQI range [0..255]
* - TI zstack: `LQI = (MAC_SPEC_ED_MAX * (RSSIdbm - ED_RF_POWER_MIN_DBM)) / (ED_RF_POWER_MAX_DBM - ED_RF_POWER_MIN_DBM);`
* where `MAC_SPEC_ED_MAX = 255`, `ED_RF_POWER_MIN_DBM = -87`, `ED_RF_POWER_MAX_DBM = -10`
* - Nordic: RSSI accuracy valid range -90 to -20 dBm
*
* SPEC COMPLIANCE NOTES:
* - ✅ Produces LQI values in mandated 0-255 range
* - ✅ Clamps RSSI to implementation-defined sensitivity window before scaling
* - ✅ Applies monotonic mapping to preserve relative ordering (spec leaves exact curve implementation-defined)
* - ⚠️ Logistic curve tuned to typical 2.4 GHz radios; may require calibration per PHY
* - ⚠️ rssiMin/rssiMax derived from runtime observation rather than PHY constants
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*/
mapRSSIToLQI(rssi) {
if (rssi < this.rssiMin) {
return 0;
}
if (rssi > this.rssiMax) {
return 255;
}
return Math.floor(255 / (1 + Math.exp(-0.13 * (rssi - (this.rssiMin + 0.45 * (this.rssiMax - this.rssiMin))))));
}
/**
* 05-3474-23 #3.3.4.3 (Link Quality Assessment)
*
* LQA_raw (c, r) = 255 * (c - c_min) / (c_max - c_min) * (r - r_min) / (r_max - r_min)
* - c_min is the lowest signal quality ever reported, i.e. for a packet that can barely be received
* - c_max is the highest signal quality ever reported, i.e. for a packet received under ideal conditions
* - r_min is the lowest signal strength ever reported, i.e. for a packet close to receiver sensitivity
* - r_max is the highest signal strength ever reported, i.e. for a packet received from a strong, close-by transmitter
* HOT PATH: Called for every incoming frame to compute link quality assessment.
*
* SPEC COMPLIANCE NOTES:
* - ✅ Computes link quality assessment (LQA) using normalized RSSI/LQI ranges per Zigbee PRO guidance
* - ✅ Ensures output range 0-255 for compatibility with NLME-LQI reports
* - ✅ Accepts externally provided LQI or derives from RSSI for MACs that omit it
* - ⚠️ Logistic coefficients tuned empirically; spec allows vendor-specific mapping
* - ⚠️ Runtime min/max windows updated elsewhere; assumes values reflect current environment
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*
* @param signalStrength RSSI value
* @param signalQuality LQI value (optional, computed from RSSI if not provided)
* @returns Computed LQA value (0-255)
*/
/* @__INLINE__ */
computeLQA(signalStrength, signalQuality) {
// HOT PATH: Map RSSI to LQI if not provided
if (signalQuality === undefined) {
signalQuality = this.mapRSSIToLQI(signalStrength);
}
// HOT PATH: Clamp signal strength to valid range
if (signalStrength < this.rssiMin) {
signalStrength = this.rssiMin;
}
if (signalStrength > this.rssiMax) {
signalStrength = this.rssiMax;
}
// HOT PATH: Clamp signal quality to valid range
if (signalQuality < this.lqiMin) {
signalQuality = this.lqiMin;
}
if (signalQuality > this.lqiMax) {
signalQuality = this.lqiMax;
}
// HOT PATH: Compute LQA with optimized formula (single Math.floor call)
return Math.floor((((255 * (signalQuality - this.lqiMin)) / (this.lqiMax - this.lqiMin)) * (signalStrength - this.rssiMin)) / (this.rssiMax - this.rssiMin));
}
/**
* 05-3474-23 #2.4.4.2.3 (Neighbor table reporting)
*
* Compute the median LQA for a device from `recentLQAs` or using `signalStrength` directly if device unknown.
* If given, stores the computed LQA from given parameters in the `recentLQAs` list of the device before computing median.
*
* SPEC COMPLIANCE NOTES:
* - ✅ Maintains rolling median of recent LQA samples for stable reporting in Mgmt_Lqi_rsp
* - ✅ Falls back to instantaneous computation when history absent, matching spec guidance
* - ✅ Resolves IEEE address from short address when needed for table lookup
* - ⚠️ Median window size configurable (default 10) - spec does not mandate exact count
* - ⚠️ Zero returned when device unknown aligns with spec allowance for missing entries
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*
* @param address16 Used to retrieve `address64` if not given (must be valid if 64 is not)
* @param address64 The IEEE address of the device
* @param signalStrength RSSI. Optional (only use existing entries if not given)
* @param signalQuality LQI. Optional (only use existing entries if not given)
* @param maxRecent Number of entries to retain in rolling window (default 10)
* @returns Median LQA for the device or 0 when unavailable
*/
computeDeviceLQA(address16, address64, signalStrength, signalQuality, maxRecent = 10) {
if (address64 === undefined && address16 !== undefined) {
address64 = this.address16ToAddress64.get(address16);
}
// sanity check
if (address64 !== undefined) {
const device = this.deviceTable.get(address64);
if (!device) {
return 0;
}
if (signalStrength !== undefined) {
const lqa = this.computeLQA(signalStrength, signalQuality);
if (device.recentLQAs.length > maxRecent) {
// remove oldest LQA if necessary
device.recentLQAs.shift();
}
device.recentLQAs.push(lqa);
}
if (device.recentLQAs.length === 0) {
return 0;
}
if (device.recentLQAs.length === 1) {
return device.recentLQAs[0];
}
const sortedLQAs = device.recentLQAs.slice( /* copy */).sort((a, b) => a - b);
const midIndex = Math.floor(sortedLQAs.length / 2);
const median = Math.floor(sortedLQAs.length % 2 === 1 ? sortedLQAs[midIndex] : (sortedLQAs[midIndex - 1] + sortedLQAs[midIndex]) / 2);
return median;
}
return signalStrength !== undefined ? this.computeLQA(signalStrength, signalQuality) : 0;
}
/**
* 05-3474-23 #3.3.1.5 (NWK radius handling)
*
* Decrement radius value for NWK frame forwarding.
* HOT PATH: Optimized computation
*
* SPEC COMPLIANCE NOTES:
* - ✅ Decrements NWK radius while enforcing minimum value of 1 as mandated
* - ✅ Substitutes CONFIG_NWK_MAX_HOPS when radius=0 (interpreted as unlimited per spec)
* - ⚠️ Does not update route record metrics; caller responsible for hop tracking
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*
* @param radius Current radius value
* @returns Decremented radius (minimum 1)
*/
/* @__INLINE__ */
decrementRadius(radius) {
const newRadius = (radius === 0 ? nwk_handler_js_1.CONFIG_NWK_MAX_HOPS : radius) - 1;
return newRadius < 1 ? 1 : newRadius;
}
/**
* Make a key for AppLinkKeyStoreEntry
* HOT PATH: Optimized computation
* @param deviceA
* @param deviceB
* @returns
*/
/* @__INLINE__ */
#makeAppLinkKeyId(deviceA, deviceB) {
return deviceA < deviceB ? `${deviceA}-${deviceB}` : `${deviceB}-${deviceA}`;
}
/**
* 05-3474-23 #4.4.11 (Trust Center link/app keys)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Retrieves stored application/link key using canonicalized IEEE pair
* - ✅ Returns undefined when pair missing, allowing caller to trigger key negotiation per spec
* - ⚠️ Does not validate key freshness; higher layers manage key attributes
* DEVICE SCOPE: Trust Center
*/
getAppLinkKey(deviceA, deviceB) {
const entry = this.appLinkKeyTable.get(this.#makeAppLinkKeyId(deviceA, deviceB));
if (entry === undefined) {
return undefined;
}
return entry.key;
}
/**
* 05-3474-23 #4.4.11.1 (Application Link Key establishment)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Stores link/application keys using sorted IEEE tuple to match spec requirement for unordered pairs
* - ✅ Keeps full 16-byte key material intact for subsequent APS ENCRYPT operations
* - ⚠️ Does not persist key attributes (VERIFIED/PROVISIONAL) – tracked elsewhere
* DEVICE SCOPE: Trust Center
*/
setAppLinkKey(deviceA, deviceB, key) {
const [canonicalA, canonicalB] = deviceA < deviceB ? [deviceA, deviceB] : [deviceB, deviceA];
const stored = {
deviceA: canonicalA,
deviceB: canonicalB,
key,
};
this.appLinkKeyTable.set(this.#makeAppLinkKeyId(canonicalA, canonicalB), stored);
}
/**
* 05-3474-23 #4.5.1 (Install Code processing)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Validates install code length against permitted sizes (8/10/14/18/22/26 bytes)
* - ✅ Verifies CRC-16 per Zigbee specification before accepting raw install codes
* - ✅ Derives link key using AES-MMO hash when provided with plain install code
* - ✅ Stores hashed value when caller already supplied derived key (e.g., from commissioning tool)
* - ⚠️ CRC computed locally; assumes little-endian order per spec Appendix B
* DEVICE SCOPE: Trust Center
*
* @param device64 IEEE address of device whose code is being stored
* @param installCode Install code or hashed key buffer (length varies)
* @param hashed Indicates that `installCode` already contains derived key material
* @returns Derived application link key associated with Trust Center
*/
addInstallCode(device64, installCode, hashed = false) {
if (this.trustCenterPolicies.installCode === InstallCodePolicy.NOT_SUPPORTED) {
throw new Error("Install codes are not supported by the current Trust Center policy");
}
const keyLength = hashed ? installCode.byteLength : installCode.byteLength - 2;
if (!zigbee_js_1.INSTALL_CODE_VALID_SIZES.some((size) => size === keyLength)) {
throw new Error(`Invalid install code length ${keyLength}`);
}
if (hashed) {
this.installCodeTable.set(device64, installCode);
this.setAppLinkKey(device64, this.netParams.eui64, installCode);
return installCode;
}
const providedCRC = installCode.readUInt16LE(keyLength);
const computedCRC = (0, zigbee_js_1.computeInstallCodeCRC)(installCode.subarray(0, keyLength));
if (providedCRC !== computedCRC) {
throw new Error("Invalid install code CRC");
}
const key = (0, zigbee_js_1.aes128MmoHash)(installCode);
this.installCodeTable.set(device64, installCode);
this.setAppLinkKey(device64, this.netParams.eui64, key);
return key;
}
/**
* 05-3474-23 #4.5.1 (Install Code lifecycle)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Removes stored install code metadata upon revocation
* - ⚠️ Leaves derived link key intact (spec allows retention for existing secure links)
* DEVICE SCOPE: Trust Center
*/
removeInstallCode(device64) {
this.installCodeTable.delete(device64);
// Keep derived link key in appLinkKeyTable; it may have been rotated independently.
}
/**
* 05-3474-23 #4.7.6 (Trust Center persistent data)
*
* Save state to file system in TLV format.
*
* SPEC COMPLIANCE NOTES:
* - ✅ Persists Trust Center datasets (network parameters, device table, link keys) between restarts
* - ✅ Adds frame-counter jump offset when storing to meet anti-replay requirements after reboot
* - ✅ Serializes TLV records for extensibility and backward compatibility
* - ⚠️ Format version tracked locally; interoperability with other implementations requires converter
* - ⚠️ Application link key attributes not currently stored (keys only)
* DEVICE SCOPE: Trust Center
*
* Format version 1:
* - VERSION tag
* - Network parameter tags (EUI64, PAN_ID, etc.)
* - DEVICE_ENTRY tags (each containing nested TLV device data)
* - END_MARKER
*/
async saveState() {
// estimate buffer size (generous upper bound)
const estimatedSize = (0, save_serializer_js_1.estimateTLVStateSize)(this.deviceTable.size, this.appLinkKeyTable.size);
const state = Buffer.allocUnsafe(estimatedSize);
let offset = 0;
// write version first
offset = (0, save_serializer_js_1.writeTLVUInt8)(state, offset, 240 /* TLVTag.VERSION */, save_serializer_js_1.SAVE_FORMAT_VERSION);
// network parameters (can be added/removed without breaking old readers)
offset = (0, save_serializer_js_1.writeTLVBigUInt64LE)(state, offset, 1 /* TLVTag.EUI64 */, this.netParams.eui64);
offset = (0, save_serializer_js_1.writeTLVUInt16LE)(state, offset, 2 /* TLVTag.PAN_ID */, this.netParams.panId);
offset = (0, save_serializer_js_1.writeTLVBigUInt64LE)(state, offset, 3 /* TLVTag.EXTENDED_PAN_ID */, this.netParams.extendedPanId);
offset = (0, save_serializer_js_1.writeTLVUInt8)(state, offset, 4 /* TLVTag.CHANNEL */, this.netParams.channel);
offset = (0, save_serializer_js_1.writeTLVUInt8)(state, offset, 5 /* TLVTag.NWK_UPDATE_ID */, this.netParams.nwkUpdateId);
offset = (0, save_serializer_js_1.writeTLVInt8)(state, offset, 6 /* TLVTag.TX_POWER */, this.netParams.txPower);
offset = (0, save_serializer_js_1.writeTLV)(state, offset, 7 /* TLVTag.NETWORK_KEY */, this.netParams.networkKey);
offset = (0, save_serializer_js_1.writeTLVUInt32LE)(state, offset, 8 /* TLVTag.NETWORK_KEY_FRAME_COUNTER */, this.netParams.networkKeyFrameCounter + CONFIG_SAVE_FRAME_COUNTER_JUMP_OFFSET);
offset = (0, save_serializer_js_1.writeTLVUInt8)(state, offset, 9 /* TLVTag.NETWORK_KEY_SEQUENCE_NUMBER */, this.netParams.networkKeySequenceNumber);
offset = (0, save_serializer_js_1.writeTLV)(state, offset, 10 /* TLVTag.TC_KEY */, this.netParams.tcKey);
offset = (0, save_serializer_js_1.writeTLVUInt32LE)(state, offset, 11 /* TLVTag.TC_KEY_FRAME_COUNTER */, this.netParams.tcKeyFrameCounter + CONFIG_SAVE_FRAME_COUNTER_JUMP_OFFSET);
// device table (count is implicit in number of DEVICE_ENTRY tags)
for (const [device64, device] of this.deviceTable) {
const sourceRouteEntries = this.sourceRouteTable.get(device.address16);
const deviceEntry = (0, save_serializer_js_1.serializeDeviceEntry)(device64, device.address16, device.capabilities ? (0, mac_js_1.encodeMACCapabilities)(device.capabilities) : 0x00, device.authorized, device.neighbor, device.lastTransportedNetworkKeySeq, sourceRouteEntries);
offset = (0, save_serializer_js_1.writeTLV)(state, offset, 128 /* TLVTag.DEVICE_ENTRY */, deviceEntry);
}
for (const entry of this.appLinkKeyTable.values()) {
const serializedEntry = (0, save_serializer_js_1.serializeAppLinkKeyEntry)(entry.deviceA, entry.deviceB, entry.key);
offset = (0, save_serializer_js_1.writeTLV)(state, offset, 12 /* TLVTag.APP_LINK_KEY_ENTRY */, serializedEntry);
}
// write end marker (aids debugging and validates complete write)
state.writeUInt8(255 /* TLVTag.END_MARKER */, offset++);
const writtenState = state.subarray(0, offset);
// write only the used portion
await (0, promises_1.writeFile)(this.#savePath, writtenState);
logger_js_1.logger.debug(() => `Saved state to ${this.#savePath} (${writtenState.byteLength} bytes)`, NS);
}
/**
* 05-3474-23 #4.7.6 (Trust Center persistent data)
*
* Read the current network state in the save file, if any present.
*
* SPEC COMPLIANCE NOTES:
* - ✅ Reads TLV state blob and validates version before applying
* - ✅ Logs metadata (PAN ID, channel) for diagnostics per spec recommendations
* - ⚠️ Unknown future versions are attempted with warning rather than hard fail (best effort)
* DEVICE SCOPE: Trust Center
*/
async readNetworkState() {
try {
const stateBuffer = await (0, promises_1.readFile)(this.#savePath);
logger_js_1.logger.debug(() => `Loaded state from ${this.#savePath} (${stateBuffer.byteLength} bytes)`, NS);
// Parse state once into typed structure with all values already converted to final types
const state = (0, save_serializer_js_1.readTLVs)(stateBuffer);
// Check version (already parsed to number)
const version = state.version ?? 1;
if (version > save_serializer_js_1.SAVE_FORMAT_VERSION) {
logger_js_1.logger.warning(`Unknown save format version ${version}, attempting to load`, NS);
}
logger_js_1.logger.debug(() => `Current save network: eui64=${state.eui64} panId=${state.panId} channel=${state.channel}`, NS);
return state;
}
catch {
/* empty */
}
}
/**
* 05-3474-23 #4.7.6 (Trust Center start-up procedure)
*
* Load state from file system if exists, else save "initial" state.
* Afterwards, various keys are pre-hashed and descriptors pre-encoded.
*
* SPEC COMPLIANCE NOTES:
* - ✅ Restores network parameters, device table, and link keys before enabling stack operations
* - ✅ Recomputes hashed keys for LINK/NWK/TRANSPORT/LOAD usage as required for secure processing
* - ✅ Initializes coordinator descriptors per Zigbee Device Objects defaults
* - ⚠️ Missing persistence for per-device incoming NWK frame counters (TODO noted)
* - ⚠️ Creates initial save file when none exists to align with spec initialization sequence
* DEVICE SCOPE: Trust Center
*/
async loadState() {
// pre-emptive
this.#loaded = true;
const state = await this.readNetworkState();
if (state) {
// Network parameters already parsed to final types - update context
this.netParams.eui64 = state.eui64;
this.netParams.panId = state.panId;
this.netParams.extendedPanId = state.extendedPanId;
this.netParams.channel = state.channel;
this.netParams.nwkUpdateId = state.nwkUpdateId;
this.netParams.txPower = state.txPower;
this.netParams.networkKey = state.networkKey;
this.netParams.networkKeyFrameCounter = state.networkKeyFrameCounter;
this.netParams.networkKeySequenceNumber = state.networkKeySequenceNumber;
this.netParams.tcKey = state.tcKey;
this.netParams.tcKeyFrameCounter = state.tcKeyFrameCounter;
// Device entries already parsed with all nested source routes
logger_js_1.logger.debug(() => `Current save devices: ${state.deviceEntries.length}`, NS);
for (const device of state.deviceEntries) {
// Device values already parsed - just destructure
const { address64, address16, capabilities, authorized, neighbor, sourceRouteEntries, lastTransportedNetworkKeySeq } = device;
const decodedCap = capabilities !== 0 ? (0, mac_js_1.decodeMACCapabilities)(capabilities) : undefined;
this.deviceTable.set(address64, {
address16,
capabilities: decodedCap,
authorized,
neighbor,
lastTransportedNetworkKeySeq,
recentLQAs: [],
incomingNWKFrameCounter: undefined, // TODO: record this (should persist across reboots)
endDeviceTimeout: undefined,
linkStatusMisses: 0, // will stay zero for RFDs
});
this.address16ToAddress64.set(address16, address64);
if (decodedCap && !decodedCap.rxOnWhenIdle) {
this.indirectTransmissions.set(address64, []);
}
if (sourceRouteEntries.length > 0) {
const routes = sourceRouteEntries.map((entry) => ({
relayAddresses: entry.relayAddresses,
pathCost: entry.pathCost,
lastUpdated: entry.lastUpdated,
failureCount: 0,
lastUsed: undefined,
}));
this.sourceRouteTable.set(address16, routes);
}
}
for (const entry of state.appLinkKeys) {
this.setAppLinkKey(entry.deviceA, entry.deviceB, entry.key);
}
}
else {
// `this.#savePath` does not exist, using constructor-given network params, do initial save
await this.saveState();
}
// pre-compute hashes for default keys for faster processing
(0, zigbee_js_1.registerDefaultHashedKeys)((0, zigbee_js_1.makeKeyedHashByType)(0 /* ZigbeeKeyType.LINK */, this.netParams.tcKey), (0, zigbee_js_1.makeKeyedHashByType)(1 /* ZigbeeKeyType.NWK */, this.netParams.networkKey), (0, zigbee_js_1.makeKeyedHashByType)(2 /* ZigbeeKeyType.TRANSPORT */, this.netParams.tcKey), (0, zigbee_js_1.makeKeyedHashByType)(3 /* ZigbeeKeyType.LOAD */, this.netParams.tcKey));
this.tcVerifyKeyHash = (0, zigbee_js_1.makeKeyedHash)(this.netParams.tcKey, 0x03 /* input byte per spec for VERIFY_KEY */);
const [address, nodeDescriptor, powerDescriptor, simpleDescriptors, activeEndpoints] = (0, descriptors_js_1.encodeCoordinatorDescriptors)(this.netParams.eui64);
this.configAttributes.address = address;
this.configAttributes.nodeDescriptor = nodeDescriptor;
this.configAttributes.powerDescriptor = powerDescriptor;
this.configAttributes.simpleDescriptors = simpleDescriptors;
this.configAttributes.activeEndpoints = activeEndpoints;
}
/**
* 05-3474-23 #2.3.2.3 (Node Descriptor)
*
* Set the manufacturer code in the pre-encoded node descriptor
*
* SPEC COMPLIANCE NOTES:
* - ✅ Writes manufacturer code at fixed offset within pre-encoded ZDO node descriptor response
* - ⚠️ Assumes descriptor already generated via `encodeCoordinatorDescriptors`
* DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A)
*
* @param code Manufacturer code assigned by CSA
*/
setManufacturerCode(code) {
this.configAttributes.nodeDescriptor.writeUInt16LE(code, 7 /* static offset */);
}
/**
* 05-3474-23 #4.7.6 (Trust Center maintenance)
*
* SPEC COMPLIANCE NOTES:
* - ✅ Persists state at configured interval while refreshing timer to maintain cadence
* - ⚠️ Interval configurable via CONFIG_SAVE_STATE_TIME (60s default)
* DEVICE SCOPE: Trust Center
*/
async savePeriodicState() {
await this.saveState();
this.#saveStateTimeout?.refresh();
}
/**
* Revert allowing joins (keeps `allowRejoinsWithWellKnownKey=true`).
*
* SPEC COMPLIANCE:
* - ✅ Clears timer correctly
* - ✅ Updates Trust Center allowJoins policy
* - ✅ Maintains allowRejoinsWithWellKnownKey for rejoins
* - ✅ Sets associationPermit flag for MAC layer
* DEVICE SCOPE: Trust Center
*/
disallowJoins() {
clearTimeout(this.#allowJoinTimeout);
this.#allowJoinTimeout = undefined;
this.trustCenterPolicies.allowJoins = false;
this.trustCenterPolicies.allowRejoinsWithWellKnownKey = true;
this.associationPermit = false;
logger_js_1.logger.info("Disallowed joins", NS);
}
/**
* SPEC COMPLIANCE:
* - ✅ Implements timed join window per spec
* - ✅ Updates Trust Center policies
* - ✅ Sets MAC associationPermit flag
* - ✅ Clamps 0xff to 0xfe for security
* - ✅ Auto-disallows after timeout
* DEVICE SCOPE: Trust Center
*
* @param duration The length of time in seconds during which the trust center will allow joins.
* The value 0x00 and 0xff indicate that permission is disabled or enabled, respectively, without a specified time limit.
* 0xff is clamped to 0xfe for security reasons
* @param macAssociationPermit If true, also allow association on coordinator itself. Ignored if duration 0.
*/
allowJoins(duration, macAssociationPermit) {
if (duration > 0) {
clearTimeout(this.#allowJoinTimeout);
this.trustCenterPolicies.allowJoins = true;
this.trustCenterPolicies.allowRejoinsWithWellKnownKey = true;
this.associationPermit = macAssociationPermit;
this.#allowJoinTimeout = setTimeout(this.disallowJoins.bind(this), Math.min(duration, 0xfe) * 1000);
logger_js_1.logger.info(`Allowed joins for ${duration} seconds (self=${macAssociationPermit})`, NS);
}
else {
this.disallowJoins();
}
}
/**
* Handle device association (initial join or rejoin)
*
* SPEC COMPLIANCE:
* - ✅ Validates allowJoins policy for initial join
* - ✅ Assigns network addresses correctly
* - ✅ Detects and handles address conflicts
* - ✅ Creates device table entries with capabilities
* - ✅ Sets up indirect transmission for rxOnWhenIdle=false
* - ✅ Returns appropriate status codes per IEEE 802.15.4
* - ✅ Triggers state save after association
* - ⚠️ Unknown rejoins succeed if allowOverride=true (potential security risk)
* - ✅ Enforces install code requirement (denies initial join when missing)
* - ✅ Detects network key changes on rejoin and schedules transport
* DEVICE SCOPE: Coordinator, routers (N/A)
*
* @param source16
* @param source64 Assumed valid if assocType === 0x00
* @param initialJoin If false, rejoin.
* @param neighbor True if the device associating is a neighbor of the coordinator
* @param capabilities MAC capabilities
* @param denyOverride Treat as MACAssociationStatus.PAN_ACCESS_DENIED
* @param allowOverride Treat as MACAssociationStatus.SUCCESS
* @returns
*/
async associate(source16, source64, initialJoin, capabilities, neighbor, denyOverride, allowOverride) {
// 0xffff when not successful and should not be retried
let newAddress16 = source16;
let status = mac_js_1.MACAssociationStatus.SUCCESS;
let unknownRejoin = false;
let requiresTransportKey = false;
if (denyOverride) {
newAddress16 = 0xffff;
status = mac_js_1.MACAssociationStatus.PAN_ACCESS_DENIED;
}
else if (allowOverride) {
if ((source16 === undefined || !this.address16ToAddress64.has(source16)) && (source64 === undefined || !this.deviceTable.has(source64))) {
// device unknown
unknownRejoin = true;
requiresTransportKey = true;
}
}
else {
if (initialJoin) {
if (this.trustCenterPolicies.allowJoins) {
if (source16 === undefined || source16 === 0 /* ZigbeeConsts.COORDINATOR_ADDRESS */ || source16 >= 65528 /* ZigbeeConsts.BCAST_MIN */) {
// MAC join (no `source16`)
newAddress16 = this.assignNetworkAddress();
if (newAddress16 === 0xffff) {
status = mac_js_1.MACAssociationStatus.PAN_FULL;
}
}
else {
const device = source64 !== undefined ? this.deviceTable.get(source64) : undefined;
if (device !== undefined) {
if (device.authorized) {
// initial join should not conflict on 64, don't allow join if it does
newAddress16 = 0xffff;
status = 240 /* ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT */;
}
}
else {
const existingAddress64 = this.address16ToAddress64.get(source16);
if (existingAddress64 !== undefined && source64 !== existingAddress64) {
// join with already taken source16
newAddress16 = this.assignNetworkAddress();
if (newAddress16 === 0xffff) {
status = mac_js_1.MACAssociationStatus.PAN_FULL;
}
else {
// tell device to use the newly generated value
status = 240 /* ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT */;
}
}
}
}
}
else {
newAddress16 = 0xffff;
status = mac_js_1.MACAssociationStatus.PAN_ACCESS_DENIED;
}
}
else {
// rejoin
if (source16 === undefined || source16 === 0 /* ZigbeeConsts.COORDINATOR_ADDRESS */ || source16 >= 65528 /* ZigbeeConsts.BCAST_MIN */) {
// rejoin without 16, generate one (XXX: never happens?)
newAddress16 = this.assignNetworkAddress();
if (newAddress16 === 0xffff) {
status = mac_js_1.MACAssociationStatus.PAN_FULL;
}
}
else {
const existingAddress64 = this.address16ToAddress64.get(source16);
if (existingAddress64 === undefined) {
// device unknown
unknownRejoin = true;
}
else if (existingAddress64 !== source64) {
// rejoin with already taken source16
newAddress16 = this.assignNetworkAddress();
if (newAddress16 === 0xffff) {
status = mac_js_1.MACAssociationStatus.PAN_FULL;
}
else {
// tell device to use the newly generated value
status = 240 /* ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT */;
}
}
}
// if rejoin, network address will be stored
// if (this.trustCenterPolicies.allowRejoinsWithWellKnownKey) {
// }
}
}
// something went wrong above
/* v8 ignore if -- @preserve */
if (newAddress16 === undefined) {
newAddress16 = 0xffff;
status = mac_js_1.MACAssociationStatus.PAN_ACCESS_DENIED;
}
// const existingDevice64 = source64 ?? (source16 !== undefined ? this.address16ToAddress64.get(source16) : undefined);
// const existingEntry = existingDevice64 !== undefined ? this.deviceTable.get(existingDevice64) : undefined;
// if (status === MACAssociationStatus.SUCCESS && neighbor) {
// const isExistingDirectChild = existingEntry?.neighbor === true;
// if (!isExistingDirectChild && initialJoin && !unknownRejoin) {
// const { childCount, routerCount } = this.countDirectChildren(existingDevice64);
// if (childCount >= CONFIG_NWK_MAX_CHILDREN) {
// newAddress16 = 0xffff;
// status = MACAssociationStatus.PAN_FULL;
// } else if (capabilities?.deviceType === 1 && routerCount >= CONFIG_NWK_MAX_ROUTERS) {
// newAddress16 = 0xffff;
// status = MACAssociationStatus.PAN_FULL;
// }
// }
// }
if (status === mac_js_1.MACAssociationStatus.SUCCESS &&
initialJoin &&
this.trustCenterPolicies.installCode === InstallCodePolicy.REQUIRED &&
(source64 === undefined || this.installCodeTable.get(source64) === undefined)) {
newAddress16 = 0xffff;
status = mac_js_1.MACAssociationStatus.PAN_ACCESS_DENIED;
}
logger_js_1.logger.debug(() => `DEVICE_JOINING[src=${source16}:${source64} newAddr16=${newAddress16} initialJ