From 029598fe8d88d2cd007fde417bca579a812fad6e Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:25:20 +0100 Subject: [PATCH 1/9] feat: support for Zigbee 4.0 --- AGENTS.md | 8 +- CONTRIBUTING.md | 2 +- biome.json | 2 +- package-lock.json | 76 +++---- package.json | 6 +- src/zigbee-stack/aps-handler.ts | 2 +- src/zigbee/tlvs.ts | 328 ++++++++++++++++++++++++++++ src/zigbee/zigbee-aps.ts | 2 +- test/compliance/aps.test.ts | 4 +- test/compliance/bdb.test.ts | 4 +- test/compliance/integration.test.ts | 2 +- test/compliance/mac.test.ts | 2 +- test/compliance/nwk-gp.test.ts | 4 +- test/compliance/nwk.test.ts | 4 +- test/compliance/security.test.ts | 6 +- test/compliance/utils.ts | 2 +- test/zigbee/tlvs.test.ts | 207 ++++++++++++++++++ 17 files changed, 598 insertions(+), 63 deletions(-) create mode 100644 src/zigbee/tlvs.ts create mode 100644 test/zigbee/tlvs.test.ts diff --git a/AGENTS.md b/AGENTS.md index 03b60cd..91f34bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -457,10 +457,10 @@ docker compose -f docker-dev/compose.yaml down - Submit sniffs/captures to help improve compatibility - Search for `TODO`, `HACK`, `XXX` markers for areas needing work - Maintain zero production dependencies policy -- Align with Zigbee 3.0 specification - - Zigbee specification (05-3474-23): Revision 23.1 - - Base device behavior (16-02828-012): v3.0.1 - - ZCL specification (07-5123): Revision 8 +- Align with Zigbee 4.0 specification + - Zigbee specification (06-3474-23): Revision 23.2 + - Base device behavior (22-65816-030): v3.1 + - ZCL specification (07-5123-08): Revision 8 - Green Power specification (14-0563-19): Version 1.1.2 - Use Wireshark property names for consistency diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 367b5cd..cbb01e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,5 +10,5 @@ Some quick guidelines to keep the codebase maintainable: - Ability to no-op expensive "optional" features - And the usuals... - Keep MAC/Zigbee property naming mostly in line with Wireshark for easier debugging -- Keep in line with the Zigbee 3.0 specification, but allow optimization due to the host-driven nature and removal of unnecessary features that won't impact compatibility +- Keep in line with the Zigbee 4.0 specification, but allow optimization due to the host-driven nature and removal of unnecessary features that won't impact compatibility - Focus on "Centralized Trust Center" implementation (at least at first) diff --git a/biome.json b/biome.json index c7b1c2b..212ede6 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/package-lock.json b/package-lock.json index 3108b47..00fc912 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "zigbee-on-host", - "version": "0.2.4", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zigbee-on-host", - "version": "0.2.4", + "version": "0.3.0", "license": "GPL-3.0-or-later", "devDependencies": { - "@biomejs/biome": "^2.3.13", + "@biomejs/biome": "^2.3.14", "@types/node": "^24.10.9", "@vitest/coverage-v8": "^4.0.18", "serialport": "^13.0.0", @@ -81,9 +81,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.13.tgz", - "integrity": "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA==", + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.14.tgz", + "integrity": "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -97,20 +97,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.3.13", - "@biomejs/cli-darwin-x64": "2.3.13", - "@biomejs/cli-linux-arm64": "2.3.13", - "@biomejs/cli-linux-arm64-musl": "2.3.13", - "@biomejs/cli-linux-x64": "2.3.13", - "@biomejs/cli-linux-x64-musl": "2.3.13", - "@biomejs/cli-win32-arm64": "2.3.13", - "@biomejs/cli-win32-x64": "2.3.13" + "@biomejs/cli-darwin-arm64": "2.3.14", + "@biomejs/cli-darwin-x64": "2.3.14", + "@biomejs/cli-linux-arm64": "2.3.14", + "@biomejs/cli-linux-arm64-musl": "2.3.14", + "@biomejs/cli-linux-x64": "2.3.14", + "@biomejs/cli-linux-x64-musl": "2.3.14", + "@biomejs/cli-win32-arm64": "2.3.14", + "@biomejs/cli-win32-x64": "2.3.14" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.13.tgz", - "integrity": "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ==", + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.14.tgz", + "integrity": "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A==", "cpu": [ "arm64" ], @@ -125,9 +125,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.13.tgz", - "integrity": "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw==", + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.14.tgz", + "integrity": "sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA==", "cpu": [ "x64" ], @@ -142,9 +142,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.13.tgz", - "integrity": "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw==", + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.14.tgz", + "integrity": "sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ==", "cpu": [ "arm64" ], @@ -159,9 +159,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.13.tgz", - "integrity": "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA==", + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.14.tgz", + "integrity": "sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg==", "cpu": [ "arm64" ], @@ -176,9 +176,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.13.tgz", - "integrity": "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw==", + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.14.tgz", + "integrity": "sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==", "cpu": [ "x64" ], @@ -193,9 +193,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.13.tgz", - "integrity": "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ==", + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.14.tgz", + "integrity": "sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ==", "cpu": [ "x64" ], @@ -210,9 +210,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.13.tgz", - "integrity": "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA==", + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.14.tgz", + "integrity": "sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A==", "cpu": [ "arm64" ], @@ -227,9 +227,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.13.tgz", - "integrity": "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ==", + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.14.tgz", + "integrity": "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 461bcf3..e0e461e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zigbee-on-host", - "version": "0.2.4", + "version": "0.3.0", "description": "Zigbee stack designed to run on a host and communicate with a radio co-processor (RCP)", "engines": { "node": "^20.19.0 || >=22.12.0" @@ -41,11 +41,11 @@ }, "homepage": "https://github.com/Nerivec/zigbee-on-host#readme", "devDependencies": { - "@biomejs/biome": "^2.3.13", + "@biomejs/biome": "^2.3.14", "@types/node": "^24.10.9", "@vitest/coverage-v8": "^4.0.18", "serialport": "^13.0.0", "typescript": "^5.9.3", "vitest": "^4.0.18" } -} +} \ No newline at end of file diff --git a/src/zigbee-stack/aps-handler.ts b/src/zigbee-stack/aps-handler.ts index faf0ef2..9211d73 100644 --- a/src/zigbee-stack/aps-handler.ts +++ b/src/zigbee-stack/aps-handler.ts @@ -95,7 +95,7 @@ export interface APSHandlerCallbacks { /** apsDuplicateEntryLifetime: Duration while APS duplicate table entries remain valid (milliseconds). Spec default ≈ 8s. */ const CONFIG_APS_DUPLICATE_TIMEOUT_MS = 8000; // TODO: verify -/** apsAckWaitDuration: Default ack wait duration per Zigbee 3.0 spec (milliseconds). */ +/** apsAckWaitDuration: Default ack wait duration per Zigbee 4.0 spec (milliseconds). */ export const CONFIG_APS_ACK_WAIT_DURATION_MS = 1600 + 500; // some extra for ZoH /** apsMaxFrameRetries: Default number of APS retransmissions when ACK is missing. */ export const CONFIG_APS_MAX_FRAME_RETRIES = 3; diff --git a/src/zigbee/tlvs.ts b/src/zigbee/tlvs.ts new file mode 100644 index 0000000..ca0d287 --- /dev/null +++ b/src/zigbee/tlvs.ts @@ -0,0 +1,328 @@ +import { ZigbeeConsts } from "./zigbee.js"; + +export const enum GlobalTlv { + /** minLen=2 */ + MANUFACTURER_SPECIFIC = 64, + /** minLen=2 */ + SUPPORTED_KEY_NEGOTIATION_METHODS = 65, + /** minLen=2 */ + PAN_ID_CONFLICT_REPORT = 66, + /** minLen=2 */ + NEXT_PAN_ID = 67, + /** minLen=4 */ + NEXT_CHANNEL_CHANGE = 68, + /** minLen=16 */ + SYMMETRIC_PASSPHRASE = 69, + /** minLen=2 */ + ROUTER_INFORMATION = 70, + /** minLen=2 */ + FRAGMENTATION_PARAMETERS = 71, + JOINER_ENCAPSULATION = 72, + BEACON_APPENDIX_ENCAPSULATION = 73, + BDB_ENCAPSULATION = 74, + CONFIGURATION_PARAMETERS = 75, + /** Zigbee Direct */ + DEVICE_CAPABILITY_EXTENSION = 76, + // Reserved = 77-255 +} + +export type ZigbeeGlobalTlvs = { + /** Should be ignored if unknown */ + [GlobalTlv.MANUFACTURER_SPECIFIC]?: { + /** uint16 */ + zigbeeManufacturerId: number; + /** variable */ + additionalData: Buffer; + }; + [GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS]?: { + /** uint8 */ + keyNegotiationProtocolsBitmask: number; + /** uint8 */ + preSharedSecretsBitmask: number; + sourceDeviceEui64: bigint | undefined; + }; + [GlobalTlv.PAN_ID_CONFLICT_REPORT]?: { + /** uint16 */ + nwkPanIdConflictCount: number; + }; + [GlobalTlv.NEXT_PAN_ID]?: { + /** uint16 */ + panId: number; + }; + [GlobalTlv.NEXT_CHANNEL_CHANGE]?: { + /** uint32 */ + channel: number; + }; + [GlobalTlv.SYMMETRIC_PASSPHRASE]?: { + /** 16-byte */ + passphrase: Buffer; + }; + [GlobalTlv.ROUTER_INFORMATION]?: { + /** uint16 */ + bitmap: number; + }; + [GlobalTlv.FRAGMENTATION_PARAMETERS]?: { + /** uint16 */ + nwkAddress: number; + /** uint8 */ + fragmentationOptions: number | undefined; + /** uint16 */ + maxIncomingTransferUnit: number | undefined; + }; + [GlobalTlv.JOINER_ENCAPSULATION]?: { + additionalTLVs: ZigbeeGlobalTlvs; + }; + [GlobalTlv.BEACON_APPENDIX_ENCAPSULATION]?: { + additionalTLVs: ZigbeeGlobalTlvs; + }; + [GlobalTlv.BDB_ENCAPSULATION]?: { + additionalTLVs: ZigbeeGlobalTlvs; + }; + [GlobalTlv.CONFIGURATION_PARAMETERS]?: { + /** uint16 */ + parameters: number; + }; + [GlobalTlv.DEVICE_CAPABILITY_EXTENSION]?: { + /** uint16 */ + capabilityExtension: number; + }; +}; + +/** + * SPEC COMPLIANCE: + * - ✅ TLV format: 1-byte tag + 1-byte length field where actual value length is length+1. + * - ✅ Local TLVs (0-63) are captured verbatim; Global TLVs (64-255) are parsed when known and ignored when unknown. + * - ✅ Rejects malformed TLVs that underflow minimum lengths or overrun the provided buffer. + * - ✅ Allows multiple Manufacturer-Specific TLVs; rejects duplicate instances of other known Global TLVs. + * - ✅ Encapsulation TLVs are supported with a single nesting level (nested encapsulation is rejected). + */ +export function readZigbeeTlvs(data: Buffer, offset: number, parent?: number): [ZigbeeGlobalTlvs, localTlvs: Map, outOffset: number] { + const globalTlvs: ZigbeeGlobalTlvs = {}; + const localTlvs = new Map(); + const endOffset = data.byteLength; + + while (offset < endOffset) { + // early bail-out if malformed + if (endOffset - offset < 2) { + throw new Error("Malformed TLVs"); + } + + // 0..63=local, 64..255=global + const tag = data.readUInt8(offset); + offset += 1; + const length = data.readUInt8(offset) + 1; // per spec, actual data length is `length field + 1` + offset += 1; + // keep a separate counter for offset of known fields being parsed + let tlvOffset = offset; + // `offset` is now TLV end offset + offset += length; + + // early bail-out if malformed + if (offset > endOffset) { + throw new Error("Malformed TLVs"); + } + + if (tag < GlobalTlv.MANUFACTURER_SPECIFIC) { + // local + if (localTlvs.has(tag)) { + throw new Error(`Invalid duplicate local TLV found tag=${tag}`); + } + + localTlvs.set(tag, data.subarray(tlvOffset, tlvOffset + length)); + } else { + // global + if (tag !== GlobalTlv.MANUFACTURER_SPECIFIC && tag in globalTlvs) { + throw new Error(`Invalid duplicate global TLV found tag=${tag}`); + } + + switch (tag) { + case GlobalTlv.MANUFACTURER_SPECIFIC: { + if (length < 2) { + throw new Error(`Malformed TLV, below minimum length (${length})`); + } + + const zigbeeManufacturerId = data.readUInt16LE(tlvOffset); + tlvOffset += 2; + const additionalData = data.subarray(tlvOffset, tlvOffset + length - 2); + + globalTlvs[GlobalTlv.MANUFACTURER_SPECIFIC] = { zigbeeManufacturerId, additionalData }; + + break; + } + case GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS: { + if (length < 2) { + throw new Error(`Malformed TLV, below minimum length (${length})`); + } + + const keyNegotiationProtocolsBitmask = data.readUInt8(tlvOffset); + tlvOffset += 1; + const preSharedSecretsBitmask = data.readUInt8(tlvOffset); + tlvOffset += 1; + let sourceDeviceEui64: bigint | undefined; + + if (length >= 10) { + sourceDeviceEui64 = data.readBigUInt64LE(tlvOffset); + } + + globalTlvs[GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS] = { + keyNegotiationProtocolsBitmask, + preSharedSecretsBitmask, + sourceDeviceEui64, + }; + + break; + } + case GlobalTlv.PAN_ID_CONFLICT_REPORT: { + if (length < 2) { + throw new Error(`Malformed TLV, below minimum length (${length})`); + } + + const nwkPanIdConflictCount = data.readUInt16LE(tlvOffset); + + globalTlvs[GlobalTlv.PAN_ID_CONFLICT_REPORT] = { nwkPanIdConflictCount }; + + break; + } + case GlobalTlv.NEXT_PAN_ID: { + if (length < 2) { + throw new Error(`Malformed TLV, below minimum length (${length})`); + } + + const panId = data.readUInt16LE(tlvOffset); + + globalTlvs[GlobalTlv.NEXT_PAN_ID] = { panId }; + + break; + } + case GlobalTlv.NEXT_CHANNEL_CHANGE: { + if (length < 4) { + throw new Error(`Malformed TLV, below minimum length (${length})`); + } + + const channel = data.readUInt32LE(tlvOffset); + + globalTlvs[GlobalTlv.NEXT_CHANNEL_CHANGE] = { channel }; + + break; + } + case GlobalTlv.SYMMETRIC_PASSPHRASE: { + if (length < ZigbeeConsts.SEC_KEYSIZE) { + throw new Error(`Malformed TLV, below minimum length (${length})`); + } + + const passphrase = data.subarray(tlvOffset, tlvOffset + ZigbeeConsts.SEC_KEYSIZE); + + globalTlvs[GlobalTlv.SYMMETRIC_PASSPHRASE] = { passphrase }; + + break; + } + case GlobalTlv.ROUTER_INFORMATION: { + if (length < 2) { + throw new Error(`Malformed TLV, below minimum length (${length})`); + } + + const bitmap = data.readUInt16LE(tlvOffset); + + globalTlvs[GlobalTlv.ROUTER_INFORMATION] = { bitmap }; + + break; + } + case GlobalTlv.FRAGMENTATION_PARAMETERS: { + if (length < 2) { + throw new Error(`Malformed TLV, below minimum length (${length})`); + } + + const nwkAddress = data.readUInt16LE(tlvOffset); + tlvOffset += 2; + let fragmentationOptions: number | undefined; + let maxIncomingTransferUnit: number | undefined; + + if (length >= 3) { + fragmentationOptions = data.readUInt8(tlvOffset); + tlvOffset += 1; + } + + if (length >= 5) { + maxIncomingTransferUnit = data.readUInt16LE(tlvOffset); + } + + globalTlvs[GlobalTlv.FRAGMENTATION_PARAMETERS] = { nwkAddress, fragmentationOptions, maxIncomingTransferUnit }; + + break; + } + case GlobalTlv.JOINER_ENCAPSULATION: { + if (parent !== undefined) { + throw new Error(`Invalid nested encapsulated TLV found tag=${tag} parent=${parent}`); + } + + // at least the length of tagId+length for first encapsulated tlv, doesn't make sense otherwise + if (length < 2) { + throw new Error(`Malformed TLV, below minimum length (${length})`); + } + + const [additionalTLVs] = readZigbeeTlvs(data.subarray(tlvOffset, tlvOffset + length), 0, tag); + + globalTlvs[GlobalTlv.JOINER_ENCAPSULATION] = { additionalTLVs }; + + break; + } + case GlobalTlv.BEACON_APPENDIX_ENCAPSULATION: { + if (parent !== undefined) { + throw new Error(`Invalid nested encapsulated TLV found tag=${tag} parent=${parent}`); + } + + // at least the length of tagId+length for first encapsulated tlv, doesn't make sense otherwise + if (length < 2) { + throw new Error(`Malformed TLV, below minimum length (${length})`); + } + + const [additionalTLVs] = readZigbeeTlvs(data.subarray(tlvOffset, tlvOffset + length), 0, tag); + + globalTlvs[GlobalTlv.BEACON_APPENDIX_ENCAPSULATION] = { additionalTLVs }; + + break; + } + case GlobalTlv.BDB_ENCAPSULATION: { + if (parent !== undefined) { + throw new Error(`Invalid nested encapsulated TLV found tag=${tag} parent=${parent}`); + } + + if (length < 2) { + // at least the length of tagId+length for first encapsulated tlv, doesn't make sense otherwise + throw new Error(`Malformed TLV, below minimum length (${length})`); + } + + const [additionalTLVs] = readZigbeeTlvs(data.subarray(tlvOffset, tlvOffset + length), 0, tag); + + globalTlvs[GlobalTlv.BDB_ENCAPSULATION] = { additionalTLVs }; + + break; + } + case GlobalTlv.CONFIGURATION_PARAMETERS: { + if (length < 2) { + throw new Error(`Malformed TLV, below minimum length (${length})`); + } + + const configurationParameters = data.readUInt16LE(tlvOffset); + + globalTlvs[GlobalTlv.CONFIGURATION_PARAMETERS] = { parameters: configurationParameters }; + + break; + } + case GlobalTlv.DEVICE_CAPABILITY_EXTENSION: { + if (length < 2) { + throw new Error(`Malformed TLV, below minimum length (${length})`); + } + + const capabilityExtension = data.readUInt16LE(tlvOffset); + + globalTlvs[GlobalTlv.DEVICE_CAPABILITY_EXTENSION] = { capabilityExtension }; + + break; + } + } + } + } + + return [globalTlvs, localTlvs, offset]; +} diff --git a/src/zigbee/zigbee-aps.ts b/src/zigbee/zigbee-aps.ts index a78fb8a..cf69660 100644 --- a/src/zigbee/zigbee-aps.ts +++ b/src/zigbee/zigbee-aps.ts @@ -141,7 +141,7 @@ export type ZigbeeAPSPayload = Buffer; * 05-3474-23 R23.1, Table 2-69 (APS frame control fields) * * SPEC COMPLIANCE NOTES: - * - ✅ Extracts frame type, delivery, security, and extended header bits per Zigbee 3.0 profile + * - ✅ Extracts frame type, delivery, security, and extended header bits per Zigbee 4.0 profile * - ✅ Treats deprecated indirect bit as reserved, matching Zigbee 2007+ behaviour * - ⚠️ Defers extended header parsing to caller since fragmentation format varies by frame type * DEVICE SCOPE: All logical devices diff --git a/test/compliance/aps.test.ts b/test/compliance/aps.test.ts index 98554e1..8c0c34f 100644 --- a/test/compliance/aps.test.ts +++ b/test/compliance/aps.test.ts @@ -4,7 +4,7 @@ * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: * - Zigbee specification (05-3474-23): Revision 23.1 - * - Base device behavior (16-02828-012): v3.0.1 + * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 * @@ -60,7 +60,7 @@ import { NETDEF_EXTENDED_PAN_ID, NETDEF_NETWORK_KEY, NETDEF_PAN_ID, NETDEF_TC_KE import { createMACFrameControl } from "../utils.js"; import { captureMacFrame, type DecodedMACFrame, decodeMACFramePayload, NO_ACK_CODE, registerNeighborDevice } from "./utils.js"; -describe("Zigbee 3.0 Application Support (APS) Layer Compliance", () => { +describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { let netParams: NetworkParameters; let saveDir: string; diff --git a/test/compliance/bdb.test.ts b/test/compliance/bdb.test.ts index f41d6f3..980337c 100644 --- a/test/compliance/bdb.test.ts +++ b/test/compliance/bdb.test.ts @@ -4,7 +4,7 @@ * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: * - Zigbee specification (05-3474-23): Revision 23.1 - * - Base device behavior (16-02828-012): v3.0.1 + * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 * @@ -48,7 +48,7 @@ import { NETDEF_EXTENDED_PAN_ID, NETDEF_NETWORK_KEY, NETDEF_PAN_ID, NETDEF_TC_KE import { createMACFrameControl } from "../utils.js"; import { captureMacFrame, cloneNetworkParameters, decodeMACFramePayload, NO_ACK_CODE, TEST_DEVICE_EUI64 } from "./utils.js"; -describe("Zigbee 3.0 Device Behavior Compliance", () => { +describe("Zigbee 4.0 Device Behavior Compliance", () => { let netParams: NetworkParameters; let saveDir: string; diff --git a/test/compliance/integration.test.ts b/test/compliance/integration.test.ts index aa67e20..6d98a7b 100644 --- a/test/compliance/integration.test.ts +++ b/test/compliance/integration.test.ts @@ -4,7 +4,7 @@ * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: * - Zigbee specification (05-3474-23): Revision 23.1 - * - Base device behavior (16-02828-012): v3.0.1 + * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 * diff --git a/test/compliance/mac.test.ts b/test/compliance/mac.test.ts index d8fe9ca..d614534 100644 --- a/test/compliance/mac.test.ts +++ b/test/compliance/mac.test.ts @@ -4,7 +4,7 @@ * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: * - Zigbee specification (05-3474-23): Revision 23.1 - * - Base device behavior (16-02828-012): v3.0.1 + * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 * diff --git a/test/compliance/nwk-gp.test.ts b/test/compliance/nwk-gp.test.ts index 86bd6a4..d0ac95a 100644 --- a/test/compliance/nwk-gp.test.ts +++ b/test/compliance/nwk-gp.test.ts @@ -4,7 +4,7 @@ * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: * - Zigbee specification (05-3474-23): Revision 23.1 - * - Base device behavior (16-02828-012): v3.0.1 + * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 * @@ -38,7 +38,7 @@ import { NETDEF_EXTENDED_PAN_ID, NETDEF_NETWORK_KEY, NETDEF_PAN_ID, NETDEF_TC_KE import { createMACHeader, createNWKGPHeader } from "../utils.js"; import { decodeMACFramePayload, NO_ACK_CODE } from "./utils.js"; -describe("Zigbee 3.0 Green Power (NWK GP) Compliance", () => { +describe("Zigbee 4.0 Green Power (NWK GP) Compliance", () => { let netParams: NetworkParameters; let saveDir: string; diff --git a/test/compliance/nwk.test.ts b/test/compliance/nwk.test.ts index 4cd7739..71898f0 100644 --- a/test/compliance/nwk.test.ts +++ b/test/compliance/nwk.test.ts @@ -4,7 +4,7 @@ * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: * - Zigbee specification (05-3474-23): Revision 23.1 - * - Base device behavior (16-02828-012): v3.0.1 + * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 * @@ -56,7 +56,7 @@ import { NETDEF_EXTENDED_PAN_ID, NETDEF_NETWORK_KEY, NETDEF_PAN_ID, NETDEF_TC_KE import { createMACFrameControl } from "../utils.js"; import { captureMacFrame, type DecodedMACFrame, decodeMACFramePayload, NO_ACK_CODE, registerNeighborDevice } from "./utils.js"; -describe("Zigbee 3.0 Network Layer (NWK) Compliance", () => { +describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { let netParams: NetworkParameters; let saveDir: string; diff --git a/test/compliance/security.test.ts b/test/compliance/security.test.ts index 194b3d9..7933c6c 100644 --- a/test/compliance/security.test.ts +++ b/test/compliance/security.test.ts @@ -4,7 +4,7 @@ * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: * - Zigbee specification (05-3474-23): Revision 23.1 - * - Base device behavior (16-02828-012): v3.0.1 + * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 * @@ -78,7 +78,7 @@ import { registerNeighborDevice, } from "./utils.js"; -describe("Zigbee 3.0 Security Compliance", () => { +describe("Zigbee 4.0 Security Compliance", () => { let netParams: NetworkParameters; let saveDir: string; @@ -911,7 +911,7 @@ describe("Zigbee 3.0 Security Compliance", () => { /** * Zigbee Spec 05-3474-23 §4.6.3.2: Well-Known Keys - * Well-known keys SHALL be used according to Zigbee 3.0 specification. + * Well-known keys SHALL be used according to Zigbee 4.0 specification. */ describe("Well-Known Keys (Zigbee §4.6.3.2)", () => { it("precomputes the trust center verify hash for the ZigBeeAlliance09 link key", () => { diff --git a/test/compliance/utils.ts b/test/compliance/utils.ts index 751a888..147bcd3 100644 --- a/test/compliance/utils.ts +++ b/test/compliance/utils.ts @@ -4,7 +4,7 @@ * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: * - Zigbee specification (05-3474-23): Revision 23.1 - * - Base device behavior (16-02828-012): v3.0.1 + * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 * diff --git a/test/zigbee/tlvs.test.ts b/test/zigbee/tlvs.test.ts new file mode 100644 index 0000000..7438506 --- /dev/null +++ b/test/zigbee/tlvs.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; +import { GlobalTlv, readZigbeeTlvs } from "../../src/zigbee/tlvs.js"; +import { ZigbeeConsts } from "../../src/zigbee/zigbee.js"; + +function makeTlv(tag: number, value: Buffer): Buffer { + if (value.length < 1) { + throw new Error("Invalid TLV test data"); + } + + const buffer = Buffer.alloc(2 + value.length); + + buffer.writeUInt8(tag, 0); + buffer.writeUInt8(value.length - 1, 1); + value.copy(buffer, 2); + + return buffer; +} + +describe("Zigbee TLVs", () => { + it("parses local and common global TLVs", () => { + const localTlv = makeTlv(1, Buffer.from([0xaa, 0xbb])); + const manufacturerSpecific = makeTlv(GlobalTlv.MANUFACTURER_SPECIFIC, Buffer.from([0x34, 0x12, 0xde, 0xad])); + const panIdConflict = makeTlv(GlobalTlv.PAN_ID_CONFLICT_REPORT, Buffer.from([0x11, 0x00])); + const nextPanId = makeTlv(GlobalTlv.NEXT_PAN_ID, Buffer.from([0x22, 0x11])); + const nextChannel = makeTlv(GlobalTlv.NEXT_CHANNEL_CHANGE, Buffer.from([0x04, 0x03, 0x02, 0x01])); + const passphrase = makeTlv(GlobalTlv.SYMMETRIC_PASSPHRASE, Buffer.alloc(ZigbeeConsts.SEC_KEYSIZE, 0xab)); + const routerInfo = makeTlv(GlobalTlv.ROUTER_INFORMATION, Buffer.from([0x44, 0x33])); + const fragmentation = makeTlv(GlobalTlv.FRAGMENTATION_PARAMETERS, Buffer.from([0x78, 0x56, 0x9a, 0xde, 0xbc])); + const configParams = makeTlv(GlobalTlv.CONFIGURATION_PARAMETERS, Buffer.from([0x55, 0x44])); + const capabilityExt = makeTlv(GlobalTlv.DEVICE_CAPABILITY_EXTENSION, Buffer.from([0x77, 0x66])); + const supportKeyNegociation = makeTlv( + GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS, + Buffer.from([0x02, 0x00, 0x78, 0x56, 0x34, 0x12, 0x00, 0x4b, 0x12, 0x00]), + ); + + const data = Buffer.concat([ + localTlv, + manufacturerSpecific, + panIdConflict, + nextPanId, + nextChannel, + passphrase, + routerInfo, + fragmentation, + configParams, + capabilityExt, + supportKeyNegociation, + ]); + + const [globalTlvs, localTlvs, outOffset] = readZigbeeTlvs(data, 0); + + expect(outOffset).toStrictEqual(data.byteLength); + expect(localTlvs.get(1)).toStrictEqual(Buffer.from([0xaa, 0xbb])); + expect(globalTlvs[GlobalTlv.MANUFACTURER_SPECIFIC]).toStrictEqual({ + zigbeeManufacturerId: 0x1234, + additionalData: Buffer.from([0xde, 0xad]), + }); + expect(globalTlvs[GlobalTlv.PAN_ID_CONFLICT_REPORT]).toStrictEqual({ nwkPanIdConflictCount: 0x0011 }); + expect(globalTlvs[GlobalTlv.NEXT_PAN_ID]).toStrictEqual({ panId: 0x1122 }); + expect(globalTlvs[GlobalTlv.NEXT_CHANNEL_CHANGE]).toStrictEqual({ channel: 0x01020304 }); + expect(globalTlvs[GlobalTlv.SYMMETRIC_PASSPHRASE]?.passphrase).toStrictEqual(Buffer.alloc(ZigbeeConsts.SEC_KEYSIZE, 0xab)); + expect(globalTlvs[GlobalTlv.ROUTER_INFORMATION]).toStrictEqual({ bitmap: 0x3344 }); + expect(globalTlvs[GlobalTlv.FRAGMENTATION_PARAMETERS]).toStrictEqual({ + nwkAddress: 0x5678, + fragmentationOptions: 0x9a, + maxIncomingTransferUnit: 0xbcde, + }); + expect(globalTlvs[GlobalTlv.CONFIGURATION_PARAMETERS]).toStrictEqual({ parameters: 0x4455 }); + expect(globalTlvs[GlobalTlv.DEVICE_CAPABILITY_EXTENSION]).toStrictEqual({ capabilityExtension: 0x6677 }); + expect(globalTlvs[GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS]).toStrictEqual({ + keyNegotiationProtocolsBitmask: 0x02, + preSharedSecretsBitmask: 0x00, + sourceDeviceEui64: 0x00124b0012345678n, + }); + }); + + it("parses supported key negotiation methods with optional sourceDeviceEui64", () => { + const supportKeyNegociation = makeTlv(GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS, Buffer.from([0x02, 0x00])); + const [globalTlvs] = readZigbeeTlvs(supportKeyNegociation, 0); + + expect(globalTlvs[GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS]).toStrictEqual({ + keyNegotiationProtocolsBitmask: 0x02, + preSharedSecretsBitmask: 0x00, + sourceDeviceEui64: undefined, + }); + }); + + it("parses fragmentation parameters with optional maxIncomingTransferUnit", () => { + const optionsOnly = makeTlv(GlobalTlv.FRAGMENTATION_PARAMETERS, Buffer.from([0x12, 0x34, 0x56])); + const [globalTlvs] = readZigbeeTlvs(optionsOnly, 0); + + expect(globalTlvs[GlobalTlv.FRAGMENTATION_PARAMETERS]).toStrictEqual({ + nwkAddress: 0x3412, + fragmentationOptions: 0x56, + maxIncomingTransferUnit: undefined, + }); + }); + + it("parses fragmentation parameters with optional fragmentationOptions,maxIncomingTransferUnit", () => { + const optionsOnly = makeTlv(GlobalTlv.FRAGMENTATION_PARAMETERS, Buffer.from([0x12, 0x34])); + const [globalTlvs] = readZigbeeTlvs(optionsOnly, 0); + + expect(globalTlvs[GlobalTlv.FRAGMENTATION_PARAMETERS]).toStrictEqual({ + nwkAddress: 0x3412, + fragmentationOptions: undefined, + maxIncomingTransferUnit: undefined, + }); + }); + + it("parses encapsulated TLVs", () => { + const joinerNested = makeTlv(GlobalTlv.CONFIGURATION_PARAMETERS, Buffer.from([0xaa, 0x55])); + const beaconNested = makeTlv(GlobalTlv.DEVICE_CAPABILITY_EXTENSION, Buffer.from([0x10, 0x00])); + const bdbNested = makeTlv(GlobalTlv.PAN_ID_CONFLICT_REPORT, Buffer.from([0x01, 0x00])); + + const joiner = makeTlv(GlobalTlv.JOINER_ENCAPSULATION, joinerNested); + const beacon = makeTlv(GlobalTlv.BEACON_APPENDIX_ENCAPSULATION, beaconNested); + const bdb = makeTlv(GlobalTlv.BDB_ENCAPSULATION, bdbNested); + + const data = Buffer.concat([joiner, beacon, bdb]); + const [globalTlvs] = readZigbeeTlvs(data, 0); + + expect(globalTlvs[GlobalTlv.JOINER_ENCAPSULATION]?.additionalTLVs[GlobalTlv.CONFIGURATION_PARAMETERS]).toStrictEqual({ + parameters: 0x55aa, + }); + expect(globalTlvs[GlobalTlv.BEACON_APPENDIX_ENCAPSULATION]?.additionalTLVs[GlobalTlv.DEVICE_CAPABILITY_EXTENSION]).toStrictEqual({ + capabilityExtension: 0x0010, + }); + expect(globalTlvs[GlobalTlv.BDB_ENCAPSULATION]?.additionalTLVs[GlobalTlv.PAN_ID_CONFLICT_REPORT]).toStrictEqual({ + nwkPanIdConflictCount: 0x0001, + }); + }); + + it("respects the offset parameter", () => { + const padding = Buffer.from([0x00, 0x01, 0x02]); + const tlv = makeTlv(GlobalTlv.NEXT_PAN_ID, Buffer.from([0x34, 0x12])); + const data = Buffer.concat([padding, tlv]); + + const [globalTlvs, , outOffset] = readZigbeeTlvs(data, padding.byteLength); + + expect(outOffset).toStrictEqual(data.byteLength); + expect(globalTlvs[GlobalTlv.NEXT_PAN_ID]).toStrictEqual({ panId: 0x1234 }); + }); + + it("throws for malformed TLVs", () => { + expect(() => readZigbeeTlvs(Buffer.from([0x00]), 0)).toThrow("Malformed TLVs"); + expect(() => readZigbeeTlvs(Buffer.from([0x40, 0x00]), 0)).toThrow("Malformed TLVs"); + }); + + it("throws for duplicate known global TLVs", () => { + const first = makeTlv(GlobalTlv.CONFIGURATION_PARAMETERS, Buffer.from([0x01, 0x00])); + const second = makeTlv(GlobalTlv.CONFIGURATION_PARAMETERS, Buffer.from([0x02, 0x00])); + + expect(() => readZigbeeTlvs(Buffer.concat([first, second]), 0)).toThrow("Invalid duplicate global TLV found tag=75"); + }); + + it("throws for duplicate known local TLVs", () => { + const first = makeTlv(23, Buffer.from([0x01, 0x00])); + const second = makeTlv(24, Buffer.from([0x01, 0x00])); + const third = makeTlv(23, Buffer.from([0x02, 0x00])); + + expect(() => readZigbeeTlvs(Buffer.concat([first, second, third]), 0)).toThrow("Invalid duplicate local TLV found tag=23"); + }); + + it("throws for nested encapsulation", () => { + const joinerNested = makeTlv(GlobalTlv.CONFIGURATION_PARAMETERS, Buffer.from([0xaa, 0x55])); + const beaconNested = makeTlv(GlobalTlv.DEVICE_CAPABILITY_EXTENSION, Buffer.from([0x10, 0x00])); + const bdbNested = makeTlv(GlobalTlv.PAN_ID_CONFLICT_REPORT, Buffer.from([0x01, 0x00])); + + const joiner = makeTlv(GlobalTlv.JOINER_ENCAPSULATION, joinerNested); + const beacon = makeTlv(GlobalTlv.BEACON_APPENDIX_ENCAPSULATION, beaconNested); + const bdb = makeTlv(GlobalTlv.BDB_ENCAPSULATION, bdbNested); + + expect(() => readZigbeeTlvs(joiner, 0, GlobalTlv.JOINER_ENCAPSULATION)).toThrow("Invalid nested encapsulated TLV found"); + expect(() => readZigbeeTlvs(joiner, 0, GlobalTlv.BEACON_APPENDIX_ENCAPSULATION)).toThrow("Invalid nested encapsulated TLV found"); + expect(() => readZigbeeTlvs(joiner, 0, GlobalTlv.BDB_ENCAPSULATION)).toThrow("Invalid nested encapsulated TLV found"); + expect(() => readZigbeeTlvs(beacon, 0, GlobalTlv.JOINER_ENCAPSULATION)).toThrow("Invalid nested encapsulated TLV found"); + expect(() => readZigbeeTlvs(beacon, 0, GlobalTlv.BEACON_APPENDIX_ENCAPSULATION)).toThrow("Invalid nested encapsulated TLV found"); + expect(() => readZigbeeTlvs(beacon, 0, GlobalTlv.BDB_ENCAPSULATION)).toThrow("Invalid nested encapsulated TLV found"); + expect(() => readZigbeeTlvs(bdb, 0, GlobalTlv.JOINER_ENCAPSULATION)).toThrow("Invalid nested encapsulated TLV found"); + expect(() => readZigbeeTlvs(bdb, 0, GlobalTlv.BEACON_APPENDIX_ENCAPSULATION)).toThrow("Invalid nested encapsulated TLV found"); + expect(() => readZigbeeTlvs(bdb, 0, GlobalTlv.BDB_ENCAPSULATION)).toThrow("Invalid nested encapsulated TLV found"); + }); + + it("throws for below-minimum TLV lengths", () => { + const cases = [ + { tag: GlobalTlv.MANUFACTURER_SPECIFIC, length: 1 }, + { tag: GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS, length: 1 }, + { tag: GlobalTlv.PAN_ID_CONFLICT_REPORT, length: 1 }, + { tag: GlobalTlv.NEXT_PAN_ID, length: 1 }, + { tag: GlobalTlv.NEXT_CHANNEL_CHANGE, length: 3 }, + { tag: GlobalTlv.SYMMETRIC_PASSPHRASE, length: ZigbeeConsts.SEC_KEYSIZE - 1 }, + { tag: GlobalTlv.ROUTER_INFORMATION, length: 1 }, + { tag: GlobalTlv.FRAGMENTATION_PARAMETERS, length: 1 }, + { tag: GlobalTlv.JOINER_ENCAPSULATION, length: 1 }, + { tag: GlobalTlv.BEACON_APPENDIX_ENCAPSULATION, length: 1 }, + { tag: GlobalTlv.BDB_ENCAPSULATION, length: 1 }, + { tag: GlobalTlv.CONFIGURATION_PARAMETERS, length: 1 }, + { tag: GlobalTlv.DEVICE_CAPABILITY_EXTENSION, length: 1 }, + ]; + + for (const { tag, length } of cases) { + const tlv = makeTlv(tag, Buffer.alloc(length, 0x00)); + + expect(() => readZigbeeTlvs(tlv, 0)).toThrow("Malformed TLV, below minimum length"); + } + }); +}); From 2cb8d5a46e5b063d6596bc889b97cc6a3adaa1bc Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:52:09 +0100 Subject: [PATCH 2/9] feat: APS handler TLVs & cleanup --- src/zigbee-stack/aps-handler.ts | 433 +++++++--------------- src/zigbee-stack/frame.ts | 38 +- src/zigbee-stack/stack-context.ts | 6 +- src/zigbee/tlvs.ts | 21 +- test/compliance/aps.test.ts | 493 ++++++++++---------------- test/compliance/integration.test.ts | 123 +------ test/compliance/security.test.ts | 58 --- test/compliance/utils.ts | 36 +- test/drivers/ot-rcp-driver.test.ts | 6 +- test/zigbee-stack/aps-handler.test.ts | 281 +++------------ test/zigbee/tlvs.test.ts | 10 +- 11 files changed, 453 insertions(+), 1052 deletions(-) diff --git a/src/zigbee-stack/aps-handler.ts b/src/zigbee-stack/aps-handler.ts index 9211d73..65aa5d4 100644 --- a/src/zigbee-stack/aps-handler.ts +++ b/src/zigbee-stack/aps-handler.ts @@ -8,8 +8,11 @@ import { type MACHeader, ZigbeeMACConsts, } from "../zigbee/mac.js"; +import { GlobalTlv, readZigbeeTlvs } from "../zigbee/tlvs.js"; import { ZigbeeConsts, ZigbeeKeyType, type ZigbeeSecurityHeader, ZigbeeSecurityLevel } from "../zigbee/zigbee.js"; import { + decodeZigbeeAPSFrameControl, + decodeZigbeeAPSHeader, encodeZigbeeAPSFrame, ZigbeeAPSCommandId, ZigbeeAPSConsts, @@ -827,7 +830,7 @@ export class APSHandler { * - ⚠️ No handling for duplicate ACKs; silently ignored once entry removed * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) */ - async #resolvePendingAck(nwkHeader: ZigbeeNWKHeader, apsHeader: ZigbeeAPSHeader): Promise { + async resolvePendingAck(nwkHeader: ZigbeeNWKHeader, apsHeader: ZigbeeAPSHeader): Promise { if (apsHeader.counter === undefined) { return; } @@ -1008,7 +1011,7 @@ export class APSHandler { * 05-3474-23 #4.4 (APS layer processing) * * SPEC COMPLIANCE NOTES: - * - ✅ Handles DATA, ACK, INTERPAN frame types per spec definitions + * - ✅ Handles DATA, INTERPAN, CMD frame types per spec definitions * - ✅ Performs duplicate rejection using APS counter + source addressing * - ✅ Performs fragmentation reassembly and forwards completed payloads upward * - ⚠️ INTERPAN frames not supported (throws) - spec optional for coordinators @@ -1023,12 +1026,6 @@ export class APSHandler { lqa: number, ): Promise { switch (apsHeader.frameControl.frameType) { - case ZigbeeAPSFrameType.ACK: { - // ACKs should never contain a payload - await this.#resolvePendingAck(nwkHeader, apsHeader); - - return; - } case ZigbeeAPSFrameType.DATA: case ZigbeeAPSFrameType.INTERPAN: { if (data.byteLength < 1) { @@ -1121,6 +1118,10 @@ export class APSHandler { await this.processCommand(data, macHeader, nwkHeader, apsHeader); break; } + case ZigbeeAPSFrameType.ACK: { + // handled upstream, codepath never reached + return; + } default: { throw new Error(`Illegal frame type ${apsHeader.frameControl.frameType}`); } @@ -1304,26 +1305,14 @@ export class APSHandler { offset += 1; switch (cmdId) { - case ZigbeeAPSCommandId.TRANSPORT_KEY: { - offset = this.processTransportKey(data, offset, macHeader, nwkHeader, apsHeader); - break; - } case ZigbeeAPSCommandId.UPDATE_DEVICE: { offset = await this.processUpdateDevice(data, offset, macHeader, nwkHeader, apsHeader); break; } - case ZigbeeAPSCommandId.REMOVE_DEVICE: { - offset = await this.processRemoveDevice(data, offset, macHeader, nwkHeader, apsHeader); - break; - } case ZigbeeAPSCommandId.REQUEST_KEY: { offset = await this.processRequestKey(data, offset, macHeader, nwkHeader, apsHeader); break; } - case ZigbeeAPSCommandId.SWITCH_KEY: { - offset = this.processSwitchKey(data, offset, macHeader, nwkHeader, apsHeader); - break; - } case ZigbeeAPSCommandId.TUNNEL: { offset = this.processTunnel(data, offset, macHeader, nwkHeader, apsHeader); break; @@ -1333,7 +1322,7 @@ export class APSHandler { break; } case ZigbeeAPSCommandId.RELAY_MESSAGE_UPSTREAM: { - offset = this.processRelayMessageUpstream(data, offset, macHeader, nwkHeader, apsHeader); + offset = await this.processRelayMessageUpstream(data, offset, macHeader, nwkHeader, apsHeader); break; } default: { @@ -1351,83 +1340,7 @@ export class APSHandler { // } } - /** - * 05-3474-23 #4.4.11.1 - * - * SPEC COMPLIANCE NOTES: - * - ✅ Handles all mandated key types (NWK, Trust Center, Application) and logs metadata - * - ✅ Stages pending network key when addressed to coordinator or wildcard destination - * - ✅ Preserves raw key material for subsequent SWITCH_KEY activation - * - ⚠️ TLV extensions for enhanced security fields remain unparsed (TODO markers) - * - ⚠️ Application key handling currently limited to storage; partner attribute updates pending - * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) - */ - public processTransportKey(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader, _apsHeader: ZigbeeAPSHeader): number { - const keyType = data.readUInt8(offset); - offset += 1; - const key = data.subarray(offset, offset + ZigbeeAPSConsts.CMD_KEY_LENGTH); - offset += ZigbeeAPSConsts.CMD_KEY_LENGTH; - - switch (keyType) { - case ZigbeeAPSConsts.CMD_KEY_STANDARD_NWK: - case ZigbeeAPSConsts.CMD_KEY_HIGH_SEC_NWK: { - const seqNum = data.readUInt8(offset); - offset += 1; - const destination = data.readBigUInt64LE(offset); - offset += 8; - const source = data.readBigUInt64LE(offset); - offset += 8; - - logger.debug( - () => - `<=== APS TRANSPORT_KEY[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} type=${keyType} key=${key} seqNum=${seqNum} dst64=${destination} src64=${source}]`, - NS, - ); - - if (destination === this.#context.netParams.eui64 || destination === 0n) { - this.#context.setPendingNetworkKey(key, seqNum); - } - - break; - } - case ZigbeeAPSConsts.CMD_KEY_TC_MASTER: - case ZigbeeAPSConsts.CMD_KEY_TC_LINK: { - const destination = data.readBigUInt64LE(offset); - offset += 8; - const source = data.readBigUInt64LE(offset); - offset += 8; - - // TODO - // const [tlvs, tlvsOutOffset] = decodeZigbeeAPSTLVs(data, offset); - - logger.debug( - () => - `<=== APS TRANSPORT_KEY[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} type=${keyType} key=${key} dst64=${destination} src64=${source}]`, - NS, - ); - break; - } - case ZigbeeAPSConsts.CMD_KEY_APP_MASTER: - case ZigbeeAPSConsts.CMD_KEY_APP_LINK: { - const partner = data.readBigUInt64LE(offset); - offset += 8; - const initiatorFlag = data.readUInt8(offset); - offset += 1; - - // TODO - // const [tlvs, tlvsOutOffset] = decodeZigbeeAPSTLVs(data, offset); - - logger.debug( - () => - `<=== APS TRANSPORT_KEY[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} type=${keyType} key=${key} partner64=${partner} initiatorFlag=${initiatorFlag}]`, - NS, - ); - break; - } - } - - return offset; - } + // NOTE: processTransportKey DEVICE SCOPE: not Trust Center (N/A) /** * 05-3474-23 #4.4.11.1 @@ -1437,7 +1350,7 @@ export class APSHandler { * - ✅ Uses UNICAST delivery mode as required by spec * - ✅ Applies both NWK security (true) and APS security (LOAD key) per spec #4.4.1.5 * - ✅ Includes destination64 and source64 (TC eui64) as mandated - * - ⚠️ TODO: TLVs not implemented (optional but recommended for R23+ features) + * - ✅ Link-Key Features & Capabilities TLV * - ⚠️ TODO: Tunneling support not implemented (optional per spec #4.6.3.7) * - ❓ UNCERTAIN: Using LOAD keyId for APS encryption - spec says "link key" but LOAD is typically used for TC link key transport * - ✅ Frame counter uses TC key counter (nextTCKeyFrameCounter) which is correct @@ -1455,16 +1368,21 @@ export class APSHandler { // It SHALL then be sent to the device specified by the TunnelAddress parameter by issuing an NLDE-DATA.request primitive. logger.debug(() => `===> APS TRANSPORT_KEY_TC[key=${key.toString("hex")} dst64=${destination64}]`, NS); - const finalPayload = Buffer.allocUnsafe(18 + ZigbeeAPSConsts.CMD_KEY_LENGTH); + const finalPayload = Buffer.allocUnsafe(21 + ZigbeeAPSConsts.CMD_KEY_LENGTH); let offset = 0; offset = finalPayload.writeUInt8(ZigbeeAPSCommandId.TRANSPORT_KEY, offset); offset = finalPayload.writeUInt8(ZigbeeAPSConsts.CMD_KEY_TC_LINK, offset); offset += key.copy(finalPayload, offset); offset = finalPayload.writeBigUInt64LE(destination64, offset); offset = finalPayload.writeBigUInt64LE(this.#context.netParams.eui64, offset); - - // TODO - // const [tlvs, tlvsOutOffset] = encodeZigbeeAPSTLVs(); + // local TLV: Link-Key Features & Capabilities + offset = finalPayload.writeUInt8(0x00, offset); + offset = finalPayload.writeUInt8(0x00, offset); // per spec, actual data length is `length field + 1` + // A set of feature flags pertaining to this security material or denoting the peer’s support for specific APS security features: + // Bit #0: Frame Counter Synchronization Support + // When set to ‘1' the peer device supports APS frame counter synchronization; else, when set to '0’, the peer device does not support APS frame counter synchronization. + // Bits #1..#7 are reserved and SHALL be set to '0' by implementations of the current Revision of this specification and ignored when processing. + offset = finalPayload.writeUInt8(0b00000001, offset); // encryption NWK=true, APS=true return await this.sendCommand( @@ -1580,7 +1498,7 @@ export class APSHandler { * - ✅ Sets CMD_KEY_APP_LINK (0x03) and includes partner64 + initiator flag per Table 4-17 * - ✅ Applies APS security with TRANSPORT keyId (shared TC link key) while suppressing NWK security (permitted) * - ✅ Supports mirrored delivery (initiator + partner) when invoked twice in Request Key flow - * - ⚠️ TODO: Add TLV support for enhanced security context (R23) + * - ✅ Link-Key Features & Capabilities TLV * - ⚠️ TODO: Consider tunneling for indirect partners per spec #4.6.3.7 * DEVICE SCOPE: Trust Center */ @@ -1588,16 +1506,21 @@ export class APSHandler { // TODO: tunneling support `, tunnelDest?: bigint` logger.debug(() => `===> APS TRANSPORT_KEY_APP[key=${key.toString("hex")} partner64=${partner} initiatorFlag=${initiatorFlag}]`, NS); - const finalPayload = Buffer.allocUnsafe(11 + ZigbeeAPSConsts.CMD_KEY_LENGTH); + const finalPayload = Buffer.allocUnsafe(14 + ZigbeeAPSConsts.CMD_KEY_LENGTH); let offset = 0; offset = finalPayload.writeUInt8(ZigbeeAPSCommandId.TRANSPORT_KEY, offset); offset = finalPayload.writeUInt8(ZigbeeAPSConsts.CMD_KEY_APP_LINK, offset); offset += key.copy(finalPayload, offset); offset = finalPayload.writeBigUInt64LE(partner, offset); offset = finalPayload.writeUInt8(initiatorFlag ? 1 : 0, offset); - - // TODO - // const [tlvs, tlvsOutOffset] = encodeZigbeeAPSTLVs(); + // local TLV: Link-Key Features & Capabilities + offset = finalPayload.writeUInt8(0x00, offset); + offset = finalPayload.writeUInt8(0x00, offset); // per spec, actual data length is `length field + 1` + // A set of feature flags pertaining to this security material or denoting the peer’s support for specific APS security features: + // Bit #0: Frame Counter Synchronization Support + // When set to ‘1' the peer device supports APS frame counter synchronization; else, when set to '0’, the peer device does not support APS frame counter synchronization. + // Bits #1..#7 are reserved and SHALL be set to '0' by implementations of the current Revision of this specification and ignored when processing. + offset = finalPayload.writeUInt8(0b00000001, offset); return await this.sendCommand( ZigbeeAPSCommandId.TRANSPORT_KEY, @@ -1627,25 +1550,21 @@ export class APSHandler { * * SPEC COMPLIANCE NOTES: * - ✅ Correctly decodes all mandatory fields: device64, device16, status - * - ⚠️ TODO: TLVs not decoded (optional but recommended for R23+ features) + * - ✅ TLVs decoded (optional but recommended for R23+ features) * - ✅ Handles 4 status codes as per spec: * 0x00 = Standard Device Secured Rejoin (updates device state via associate) * 0x01 = Standard Device Unsecured Join * 0x02 = Device Left * 0x03 = Standard Device Trust Center Rejoin - * - ⚠️ IMPLEMENTATION: Status 0x01 (Unsecured Join) handling: + * - ✅ Status 0x01 (Unsecured Join) handling: * - Calls context associate with initial join=true ✅ * - Sets neighbor=false ✅ (device joined through router) * - allowOverride=true ✅ (was allowed by parent) * - Creates source route through parent ✅ * - Sends TUNNEL(TRANSPORT_KEY) to parent for relay ✅ - * - ⚠️ SPEC CONCERN: Tunneling TRANSPORT_KEY for nested joins: - * - Uses TUNNEL command per spec #4.6.3.7 ✅ - * - Encrypts tunneled APS frame with TRANSPORT keyId ✅ - * - However, should verify parent can relay before trusting join - * - ✅ Status 0x03 (TC Rejoin) re-distributes NWK key when device lacks latest sequence * - ⚠️ Status 0x02 (Device Left) handling uses onDisassociate - spec says "informative only, should not take action" * This may be non-compliant as it actively removes the device + * - ✅ Status 0x03 (TC Rejoin) re-distributes NWK key when device lacks latest sequence * * SECURITY CONCERN: * - Unsecured joins through routers rely heavily on parent router trust @@ -1667,9 +1586,28 @@ export class APSHandler { offset += 2; const status = data.readUInt8(offset); offset += 1; + // joiner TLVs: one or more TLVs received during Network Commissioning by the parent router, not present if @@ -1764,101 +1702,18 @@ export class APSHandler { } } else if (status === ZigbeeAPSUpdateDeviceStatus.DEVICE_LEFT) { // left - // TODO: according to spec, this is "informative" only, should not take any action? + // TODO: according to spec: + // A Device Left is considered informative but SHOULD NOT be considered authoritative. + // Security related actions SHALL not be taken on receipt of this. No further processing SHALL be done. await this.#context.disassociate(device16, device64); } return offset; } - /** - * 05-3474-23 #4.4.11.2 - * - * @param nwkDest16 device that SHALL be sent the update information - * @param device64 device whose status is being updated - * @param device16 device whose status is being updated - * @param status Indicates the updated status of the device given by the device64 parameter: - * - 0x00 = Standard Device Secured Rejoin - * - 0x01 = Standard Device Unsecured Join - * - 0x02 = Device Left - * - 0x03 = Standard Device Trust Center Rejoin - * - 0x04 – 0x07 = Reserved - * @param tlvs as relayed during Network Commissioning - * @returns - * DEVICE SCOPE: Coordinator, routers (N/A) - */ - public async sendUpdateDevice( - nwkDest16: number, - device64: bigint, - device16: number, - status: number, - // tlvs: unknown[], - ): Promise { - logger.debug(() => `===> APS UPDATE_DEVICE[dev=${device16}:${device64} status=${status}]`, NS); - - const finalPayload = Buffer.allocUnsafe(12 /* + TLVs */); - let offset = 0; - offset = finalPayload.writeUInt8(ZigbeeAPSCommandId.UPDATE_DEVICE, offset); - offset = finalPayload.writeBigUInt64LE(device64, offset); - offset = finalPayload.writeUInt16LE(device16, offset); - offset = finalPayload.writeUInt8(status, offset); + // NOTE: sendUpdateDevice DEVICE SCOPE: not Trust Center (N/A) - // TODO TLVs - - return await this.sendCommand( - ZigbeeAPSCommandId.UPDATE_DEVICE, - finalPayload, - ZigbeeNWKRouteDiscovery.SUPPRESS, // nwkDiscoverRoute - true, // nwkSecurity - nwkDest16, // nwkDest16 - undefined, // nwkDest64 - ZigbeeAPSDeliveryMode.UNICAST, // apsDeliveryMode - undefined, // apsSecurityHeader - ); - } - - /** - * 05-3474-23 #4.4.11.3 - * - * SPEC COMPLIANCE: - * - ✅ Correctly decodes target IEEE address (childInfo) - * - ✅ Issues NWK leave to child and removes from device tables - * - ⚠️ Does not notify parent router beyond leave (spec expects UPDATE_DEVICE relays) - * - ⚠️ Parent role handling limited to direct coordinator actions - * DEVICE SCOPE: Coordinator, routers (N/A) - */ - public async processRemoveDevice( - data: Buffer, - offset: number, - macHeader: MACHeader, - nwkHeader: ZigbeeNWKHeader, - _apsHeader: ZigbeeAPSHeader, - ): Promise { - const target = data.readBigUInt64LE(offset); - offset += 8; - - logger.debug( - () => - `<=== APS REMOVE_DEVICE[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} target64=${target}]`, - NS, - ); - - const childEntry = this.#context.deviceTable.get(target); - - if (childEntry !== undefined) { - const leaveSent = await this.#nwkHandler.sendLeave(childEntry.address16, false); - - if (!leaveSent) { - logger.warning(`<=x= APS REMOVE_DEVICE[target64=${target}] Failed to send NWK leave`, NS); - } - - await this.#context.disassociate(childEntry.address16, target); - } else { - logger.warning(`<=x= APS REMOVE_DEVICE[target64=${target}] Unknown device`, NS); - } - - return offset; - } + // NOTE: processRemoveDevice DEVICE SCOPE: not Trust Center (N/A) /** * 05-3474-23 #4.4.11.3 @@ -1992,77 +1847,9 @@ export class APSHandler { return offset; } - /** - * 05-3474-23 #4.4.11.4 (APS Request Key) - * - * SPEC COMPLIANCE NOTES: - * - ✅ Encodes keyType and optional partner64 fields per Table 4-18 - * - ✅ Uses UNICAST delivery with NWK security as mandated - * - ⚠️ Application key partner validation limited to lookup in device table - * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) - * - * @param nwkDest16 - * @param keyType SHALL be set to the key being requested - * - 0x02: App link key - * - 0x04: TC link key - * @param partner64 When the RequestKeyType field is 2 (that is, an application key), - * the partner address field SHALL contain the extended 64-bit address of the partner device that SHALL be sent the key. - * Both the partner device and the device originating the request-key command will be sent the key. - * @returns - */ - public async sendRequestKey(nwkDest16: number, keyType: 0x02, partner64: bigint): Promise; - public async sendRequestKey(nwkDest16: number, keyType: 0x04): Promise; - public async sendRequestKey(nwkDest16: number, keyType: 0x02 | 0x04, partner64?: bigint): Promise { - logger.debug(() => `===> APS REQUEST_KEY[type=${keyType} partner64=${partner64}]`, NS); - - const hasPartner64 = keyType === ZigbeeAPSConsts.CMD_KEY_APP_MASTER; - const finalPayload = Buffer.allocUnsafe(2 + (hasPartner64 ? 8 : 0)); - let offset = 0; - offset = finalPayload.writeUInt8(ZigbeeAPSCommandId.REQUEST_KEY, offset); - offset = finalPayload.writeUInt8(keyType, offset); - - if (hasPartner64) { - offset = finalPayload.writeBigUInt64LE(partner64!, offset); - } - - return await this.sendCommand( - ZigbeeAPSCommandId.REQUEST_KEY, - finalPayload, - ZigbeeNWKRouteDiscovery.SUPPRESS, // nwkDiscoverRoute - true, // nwkSecurity - nwkDest16, // nwkDest16 - undefined, // nwkDest64 - ZigbeeAPSDeliveryMode.UNICAST, // apsDeliveryMode - undefined, // apsSecurityHeader - ); - } - - /** - * 05-3474-23 #4.4.11.3 - * - * SPEC COMPLIANCE: - * - ✅ Decodes sequence number identifying the pending network key - * - ✅ Activates staged key via StackContext.activatePendingNetworkKey - * - ✅ Resets NWK frame counter following activation - * - ⚠️ Pending key staging remains prerequisite (TRANSPORT_KEY) - * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) - */ - public processSwitchKey(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader, _apsHeader: ZigbeeAPSHeader): number { - const seqNum = data.readUInt8(offset); - offset += 1; - - logger.debug( - () => - `<=== APS SWITCH_KEY[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} seqNum=${seqNum}]`, - NS, - ); - - if (!this.#context.activatePendingNetworkKey(seqNum)) { - logger.warning(`<=x= APS SWITCH_KEY[seqNum=${seqNum}] Received without pending key`, NS); - } + // NOTE: sendRequestKey DEVICE SCOPE: not Trust Center (N/A) - return offset; - } + // NOTE: processSwitchKey DEVICE SCOPE: not Trust Center (N/A) /** * 05-3474-23 #4.4.11.5 @@ -2309,18 +2096,27 @@ export class APSHandler { * 05-3474-23 #4.4.11.9 * * SPEC COMPLIANCE: - * - ❌ NOT IMPLEMENTED + * - TODO * * DEVICE SCOPE: Trust Center */ - public async sendRelayMessageDownstream(nwkDest16: number): Promise { - logger.debug(() => `===> APS RELAY_MESSAGE_DOWNSTREAM[${"TODO"}]`, NS); + public async sendRelayMessageDownstream( + nwkDest16: number | undefined, + nwkDest64: bigint | undefined, + destination64: bigint, + tApsFrame: Buffer, + ): Promise { + logger.debug(() => `===> APS RELAY_MESSAGE_DOWNSTREAM[nwkSrc=${nwkDest16}:${nwkDest64}]`, NS); - const finalPayload = Buffer.allocUnsafe(1); + const tApsFrameLength = tApsFrame.byteLength; + const finalPayload = Buffer.allocUnsafe(11 + tApsFrameLength); let offset = 0; offset = finalPayload.writeUInt8(ZigbeeAPSCommandId.RELAY_MESSAGE_DOWNSTREAM, offset); - - // TODO + // local TLV: Relay Message + offset = finalPayload.writeUInt8(0x00, offset); + offset = finalPayload.writeUInt8(8 + tApsFrameLength - 1, offset); // per spec, actual data length is `length field + 1` + offset = finalPayload.writeBigUInt64LE(destination64, offset); + offset += tApsFrame.copy(finalPayload, offset, 0); const result = await this.sendCommand( ZigbeeAPSCommandId.RELAY_MESSAGE_DOWNSTREAM, @@ -2328,7 +2124,7 @@ export class APSHandler { ZigbeeNWKRouteDiscovery.SUPPRESS, // nwkDiscoverRoute false, // nwkSecurity nwkDest16, // nwkDest16 - undefined, // nwkDest64 + nwkDest64, // nwkDest64 ZigbeeAPSDeliveryMode.UNICAST, // apsDeliveryMode undefined, // apsSecurityHeader true, // disableACKRequest @@ -2341,37 +2137,70 @@ export class APSHandler { * 05-3474-23 #4.4.11.10 * * SPEC COMPLIANCE: - * - ⚠️ R23 feature with minimal implementation - * - ✅ Structure parsing exists (source64) - * - ❌ NOT IMPLEMENTED: Message relaying functionality - * - ❌ NOT IMPLEMENTED: TLV processing - * - ❌ NOT IMPLEMENTED: Fragment handling + * - TODO * * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) */ - public processRelayMessageUpstream( + public async processRelayMessageUpstream( data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader, _apsHeader: ZigbeeAPSHeader, - ): number { - // TODO: TLVs + ): Promise { + const [, localTlvs, tlvsOutOffset] = readZigbeeTlvs(data, offset); + offset = tlvsOutOffset; + const relayMessageTlv = localTlvs.get(0x00); - // This contains the EUI64 of the unauthorized neighbor that is the source of the relayed message. - const source64 = data.readBigUInt64LE(offset); - offset += 8; - // This contains the single APS message, or message fragment, to be relayed from the joining device to the Trust Center. - // The message SHALL start with the APS Header of the intended recipient. - // const message = ??; + if (relayMessageTlv !== undefined) { + const destination64 = relayMessageTlv.readBigUInt64LE(0); - logger.debug( - () => - `<=== APS RELAY_MESSAGE_UPSTREAM[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} src64=${source64}]`, - NS, - ); + logger.debug( + () => + `<=== APS RELAY_MESSAGE_UPSTREAM[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} dst64=${destination64}]`, + NS, + ); - // TODO: sendRelayMessageDownstream APS ACK if required by wrapped message + const [apsFCF, apsFCFOutOffset] = decodeZigbeeAPSFrameControl(relayMessageTlv, 8); + const [apsHeader] = decodeZigbeeAPSHeader(relayMessageTlv, apsFCFOutOffset, apsFCF); + + if (apsHeader.frameControl.ackRequest && nwkHeader.source16 !== ZigbeeConsts.COORDINATOR_ADDRESS) { + const ackNeedsFragmentInfo = + apsHeader.frameControl.extendedHeader && + apsHeader.fragmentation !== undefined && + apsHeader.fragmentation !== ZigbeeAPSFragmentation.NONE; + const ackHeader: ZigbeeAPSHeader = { + frameControl: { + frameType: ZigbeeAPSFrameType.ACK, + deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, + ackFormat: false, + security: false, + ackRequest: false, + extendedHeader: ackNeedsFragmentInfo, + }, + destEndpoint: apsHeader.sourceEndpoint, + clusterId: apsHeader.clusterId, + profileId: apsHeader.profileId, + sourceEndpoint: apsHeader.destEndpoint, + counter: apsHeader.counter, + }; + + if (ackNeedsFragmentInfo) { + ackHeader.fragmentation = ZigbeeAPSFragmentation.FIRST; + ackHeader.fragBlockNumber = apsHeader.fragBlockNumber ?? 0; + ackHeader.fragACKBitfield = 0x01; + } + + const apsFrame = encodeZigbeeAPSFrame( + ackHeader, + Buffer.alloc(0), // TODO optimize + // undefined, + // undefined, + ); + + await this.sendRelayMessageDownstream(nwkHeader.source16, nwkHeader.source64, destination64, apsFrame); + } + } return offset; } diff --git a/src/zigbee-stack/frame.ts b/src/zigbee-stack/frame.ts index 51211ec..c5f45ea 100644 --- a/src/zigbee-stack/frame.ts +++ b/src/zigbee-stack/frame.ts @@ -171,24 +171,28 @@ export async function processFrame( await apsHandler.sendACK(macHeader, nwkHeader, apsHeader); } - // Delegate APS duplicate check to APS handler - if (apsHeader.frameControl.frameType !== ZigbeeAPSFrameType.ACK && apsHandler.isDuplicateFrame(nwkHeader, apsHeader)) { - logger.debug(() => `<=~= APS Ignoring duplicate frame nwkSeqNum=${nwkHeader.seqNum} counter=${apsHeader.counter}`, NS); - return; - } - - const apsPayload = decodeZigbeeAPSPayload( - nwkPayload, - apsHOutOffset, - undefined, // use pre-hashed this.context.netParams.tcKey, - /* nwkHeader.frameControl.extendedSource ? nwkHeader.source64 : this.context.address16ToAddress64.get(nwkHeader.source16!) */ - nwkHeader.source64 ?? context.address16ToAddress64.get(nwkHeader.source16!), - apsFCF, - apsHeader, - ); + if (apsHeader.frameControl.frameType === ZigbeeAPSFrameType.ACK) { + await apsHandler.resolvePendingAck(nwkHeader, apsHeader); + } else { + // Delegate APS duplicate check to APS handler + if (apsHandler.isDuplicateFrame(nwkHeader, apsHeader)) { + logger.debug(() => `<=~= APS Ignoring duplicate frame nwkSeqNum=${nwkHeader.seqNum} counter=${apsHeader.counter}`, NS); + return; + } + + const apsPayload = decodeZigbeeAPSPayload( + nwkPayload, + apsHOutOffset, + undefined, // use pre-hashed this.context.netParams.tcKey, + /* nwkHeader.frameControl.extendedSource ? nwkHeader.source64 : this.context.address16ToAddress64.get(nwkHeader.source16!) */ + nwkHeader.source64 ?? context.address16ToAddress64.get(nwkHeader.source16!), + apsFCF, + apsHeader, + ); - // Delegate APS frame processing to APS handler - await apsHandler.processFrame(apsPayload, macHeader, nwkHeader, apsHeader, sourceLQA); + // Delegate APS frame processing to APS handler + await apsHandler.processFrame(apsPayload, macHeader, nwkHeader, apsHeader, sourceLQA); + } } else if (nwkFCF.frameType === ZigbeeNWKFrameType.CMD) { // Delegate NWK command processing to NWK handler await nwkHandler.processCommand(nwkPayload, macHeader, nwkHeader); diff --git a/src/zigbee-stack/stack-context.ts b/src/zigbee-stack/stack-context.ts index b3a53d6..a624277 100644 --- a/src/zigbee-stack/stack-context.ts +++ b/src/zigbee-stack/stack-context.ts @@ -568,15 +568,17 @@ export class StackContext { * Activate the staged network key if the sequence number matches. * Resets frame counters and re-registers hashed keys for cryptographic operations. * + * Expected flow is `apsHandler.sendSwitchKey` => if returned true => `activatePendingNetworkKey` + * * SPEC COMPLIANCE NOTES: - * - ✅ Activates staged key only when sequence matches SWITCH_KEY command + * - ✅ Activates staged key only when sequence matches * - ✅ 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 + * @param sequenceNumber * @returns true when activation succeeded, false when no matching pending key exists */ public activatePendingNetworkKey(sequenceNumber: number): boolean { diff --git a/src/zigbee/tlvs.ts b/src/zigbee/tlvs.ts index ca0d287..fa2fc01 100644 --- a/src/zigbee/tlvs.ts +++ b/src/zigbee/tlvs.ts @@ -70,13 +70,16 @@ export type ZigbeeGlobalTlvs = { maxIncomingTransferUnit: number | undefined; }; [GlobalTlv.JOINER_ENCAPSULATION]?: { - additionalTLVs: ZigbeeGlobalTlvs; + additionalTlvs: ZigbeeGlobalTlvs; + additionalLocalTlvs: Map; }; [GlobalTlv.BEACON_APPENDIX_ENCAPSULATION]?: { - additionalTLVs: ZigbeeGlobalTlvs; + additionalTlvs: ZigbeeGlobalTlvs; + additionalLocalTlvs: Map; }; [GlobalTlv.BDB_ENCAPSULATION]?: { - additionalTLVs: ZigbeeGlobalTlvs; + additionalTlvs: ZigbeeGlobalTlvs; + additionalLocalTlvs: Map; }; [GlobalTlv.CONFIGURATION_PARAMETERS]?: { /** uint16 */ @@ -260,9 +263,9 @@ export function readZigbeeTlvs(data: Buffer, offset: number, parent?: number): [ throw new Error(`Malformed TLV, below minimum length (${length})`); } - const [additionalTLVs] = readZigbeeTlvs(data.subarray(tlvOffset, tlvOffset + length), 0, tag); + const [additionalTlvs, additionalLocalTlvs] = readZigbeeTlvs(data.subarray(tlvOffset, tlvOffset + length), 0, tag); - globalTlvs[GlobalTlv.JOINER_ENCAPSULATION] = { additionalTLVs }; + globalTlvs[GlobalTlv.JOINER_ENCAPSULATION] = { additionalTlvs, additionalLocalTlvs }; break; } @@ -276,9 +279,9 @@ export function readZigbeeTlvs(data: Buffer, offset: number, parent?: number): [ throw new Error(`Malformed TLV, below minimum length (${length})`); } - const [additionalTLVs] = readZigbeeTlvs(data.subarray(tlvOffset, tlvOffset + length), 0, tag); + const [additionalTlvs, additionalLocalTlvs] = readZigbeeTlvs(data.subarray(tlvOffset, tlvOffset + length), 0, tag); - globalTlvs[GlobalTlv.BEACON_APPENDIX_ENCAPSULATION] = { additionalTLVs }; + globalTlvs[GlobalTlv.BEACON_APPENDIX_ENCAPSULATION] = { additionalTlvs, additionalLocalTlvs }; break; } @@ -292,9 +295,9 @@ export function readZigbeeTlvs(data: Buffer, offset: number, parent?: number): [ throw new Error(`Malformed TLV, below minimum length (${length})`); } - const [additionalTLVs] = readZigbeeTlvs(data.subarray(tlvOffset, tlvOffset + length), 0, tag); + const [additionalTlvs, additionalLocalTlvs] = readZigbeeTlvs(data.subarray(tlvOffset, tlvOffset + length), 0, tag); - globalTlvs[GlobalTlv.BDB_ENCAPSULATION] = { additionalTLVs }; + globalTlvs[GlobalTlv.BDB_ENCAPSULATION] = { additionalTlvs, additionalLocalTlvs }; break; } diff --git a/test/compliance/aps.test.ts b/test/compliance/aps.test.ts index 8c0c34f..2ed6633 100644 --- a/test/compliance/aps.test.ts +++ b/test/compliance/aps.test.ts @@ -16,6 +16,7 @@ import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { encodeMACFrameZigbee, MACFrameAddressMode, MACFrameType, type MACHeader, ZigbeeMACConsts } from "../../src/zigbee/mac.js"; +import { readZigbeeTlvs } from "../../src/zigbee/tlvs.js"; import { makeKeyedHashByType, registerDefaultHashedKeys, ZigbeeConsts, ZigbeeKeyType, ZigbeeSecurityLevel } from "../../src/zigbee/zigbee.js"; import { decodeZigbeeAPSFrameControl, @@ -34,7 +35,6 @@ import { decodeZigbeeNWKHeader, decodeZigbeeNWKPayload, encodeZigbeeNWKFrame, - ZigbeeNWKCommandId, ZigbeeNWKConsts, ZigbeeNWKFrameType, type ZigbeeNWKHeader, @@ -153,11 +153,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { return { nwkFrameControl, nwkHeader, apsFrameControl, apsHeader, apsPayload, nwkPayload }; } - /** - * Zigbee Spec 05-3474-23 §2.2.5: APS Frame Format - * The APS frame SHALL consist of a frame control field, addressing fields, - * and frame payload. - */ describe("APS Frame Format (Zigbee §2.2.5)", () => { const unicastDest16 = 0x2222; const unicastDest64 = 0x00124b00aaccef01n; @@ -333,10 +328,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { }); }); - /** - * Zigbee Spec 05-3474-23 §2.2.5.1.4: APS Counter - * The APS counter SHALL be an 8-bit value incremented for each transmission. - */ describe("APS Counter (Zigbee §2.2.5.1.4)", () => { const neighbor16 = 0x2a2a; const neighbor64 = 0x00124b00abcddc01n; @@ -533,10 +524,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { }); }); - /** - * Zigbee Spec 05-3474-23 §2.2.6: APS Addressing - * APS addressing SHALL use endpoint, cluster, and profile identifiers. - */ describe("APS Addressing (Zigbee §2.2.6)", () => { const neighbor16 = 0x3344; const neighbor64 = 0x00124b00bbccddeen; @@ -638,10 +625,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { }); }); - /** - * Zigbee Spec 05-3474-23 §2.2.8: APS Data Service - * APS data frames SHALL transport application payloads between endpoints. - */ describe("APS Data Service (Zigbee §2.2.8)", () => { const unicastDest16 = 0x7788; const unicastDest64 = 0x00124b00ddccbb11n; @@ -734,10 +717,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { }); }); - /** - * Zigbee Spec 05-3474-23 §2.2.9: APS Acknowledgment - * APS acknowledgments SHALL be sent when requested to confirm delivery. - */ describe("APS Acknowledgment (Zigbee §2.2.9)", () => { it("encodes APS acknowledgments with matching addressing and counter", async () => { const device16 = 0x3456; @@ -1025,10 +1004,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { }); }); - /** - * Zigbee Spec 05-3474-23 §4.4.3: APS Transport Key Command - * Transport key command SHALL be used to distribute security keys. - */ describe("APS Transport Key Command (Zigbee §4.4.3)", () => { const child16 = 0x5c5d; const child64 = 0x00124b00ddddeee1n; @@ -1096,7 +1071,7 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { expect(apsPayload.readUInt8(0)).toStrictEqual(ZigbeeAPSCommandId.TRANSPORT_KEY); expect(apsPayload.readUInt8(1)).toStrictEqual(ZigbeeAPSConsts.CMD_KEY_TC_LINK); - expect(apsPayload.length).toStrictEqual(1 + 1 + ZigbeeAPSConsts.CMD_KEY_LENGTH + 8 + 8); + expect(apsPayload.length).toStrictEqual(1 + 1 + ZigbeeAPSConsts.CMD_KEY_LENGTH + 8 + 8 + 3); const keyOffset = 2; const destOffset = keyOffset + ZigbeeAPSConsts.CMD_KEY_LENGTH; @@ -1105,6 +1080,10 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { expect(apsPayload.subarray(keyOffset, destOffset)).toStrictEqual(linkKey); expect(apsPayload.readBigUInt64LE(destOffset)).toStrictEqual(child64); expect(apsPayload.readBigUInt64LE(sourceOffset)).toStrictEqual(context.netParams.eui64); + // local TLV: Link-Key Features & Capabilities + expect(apsPayload.readUInt8(sourceOffset + 8)).toStrictEqual(0x00); + expect(apsPayload.readUInt8(sourceOffset + 9)).toStrictEqual(0x00); + expect(apsPayload.readUInt8(sourceOffset + 10)).toStrictEqual(0b00000001); }); it("encodes application link key transport with partner address and initiator flag", async () => { @@ -1122,7 +1101,7 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { expect(apsPayload.readUInt8(0)).toStrictEqual(ZigbeeAPSCommandId.TRANSPORT_KEY); expect(apsPayload.readUInt8(1)).toStrictEqual(ZigbeeAPSConsts.CMD_KEY_APP_LINK); - expect(apsPayload.length).toStrictEqual(1 + 1 + ZigbeeAPSConsts.CMD_KEY_LENGTH + 8 + 1); + expect(apsPayload.length).toStrictEqual(1 + 1 + ZigbeeAPSConsts.CMD_KEY_LENGTH + 8 + 1 + 3); const keyOffset = 2; const partnerOffset = keyOffset + ZigbeeAPSConsts.CMD_KEY_LENGTH; @@ -1134,10 +1113,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { }); }); - /** - * Zigbee Spec 05-3474-23 §4.4.4: APS Update Device Command - * Update device command SHALL notify of device status changes. - */ describe("APS Update Device Command (Zigbee §4.4.4)", () => { const parent16 = 0x2468; const parent64 = 0x00124b00aaaabbbcn; @@ -1158,32 +1133,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { return { nwkFrameControl, nwkHeader, apsFrameControl, apsHeader, apsPayload }; } - it.each([ - ["secure rejoin", ZigbeeAPSConsts.CMD_UPDATE_STANDARD_SEC_REJOIN], - ["unsecured join", ZigbeeAPSConsts.CMD_UPDATE_STANDARD_UNSEC_JOIN], - ["device left", ZigbeeAPSConsts.CMD_UPDATE_LEAVE], - ["trust center rejoin", ZigbeeAPSConsts.CMD_UPDATE_STANDARD_UNSEC_REJOIN], - ])("encodes update device payload for %s status", async (_label, status) => { - const device16 = 0x3500 + status; - const device64 = 0x00124b00cccce000n + BigInt(status); - - const frame = await captureMacFrame(() => apsHandler.sendUpdateDevice(parent16, device64, device16, status), mockMACHandlerCallbacks); - const { nwkFrameControl, apsFrameControl, apsPayload } = decodeAPSCommandFrame(frame); - - expect(nwkFrameControl.frameType).toStrictEqual(ZigbeeNWKFrameType.DATA); - expect(nwkFrameControl.security).toStrictEqual(true); - - expect(apsFrameControl.frameType).toStrictEqual(ZigbeeAPSFrameType.CMD); - expect(apsFrameControl.security).toStrictEqual(false); - expect(apsFrameControl.ackRequest).toStrictEqual(true); - - expect(apsPayload.length).toStrictEqual(1 + 8 + 2 + 1); - expect(apsPayload.readUInt8(0)).toStrictEqual(ZigbeeAPSCommandId.UPDATE_DEVICE); - expect(apsPayload.readBigUInt64LE(1)).toStrictEqual(device64); - expect(apsPayload.readUInt16LE(9)).toStrictEqual(device16); - expect(apsPayload.readUInt8(11)).toStrictEqual(status); - }); - it("associates devices and tunnels the current network key for unsecured joins", async () => { const frames: Buffer[] = []; mockMACHandlerCallbacks.onSendFrame = vi.fn((payload: Buffer) => { @@ -1433,10 +1382,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { }); }); - /** - * Zigbee Spec 05-3474-23 §4.4.5: APS Remove Device Command - * Remove device command SHALL instruct a device to remove a child. - */ describe("APS Remove Device Command (Zigbee §4.4.5)", () => { const parent16 = 0x2a2b; const parent64 = 0x00124b00aaaafff1n; @@ -1469,95 +1414,8 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { mockMACHandlerCallbacks.onSendFrame = vi.fn(); }); - - it("processes remove device command by issuing leave and clearing child state", async () => { - const frames: Buffer[] = []; - mockMACHandlerCallbacks.onSendFrame = vi.fn((payload: Buffer) => { - frames.push(Buffer.from(payload)); - return Promise.resolve(); - }); - - const child16 = 0x4d4e; - const child64 = 0x00124b00cccddaa1n; - registerNeighborDevice(context, child16, child64); - - const payload = Buffer.alloc(1 + 8); - payload.writeUInt8(ZigbeeAPSCommandId.REMOVE_DEVICE, 0); - payload.writeBigUInt64LE(child64, 1); - - const macHeader: MACHeader = { - frameControl: createMACFrameControl(MACFrameType.DATA, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), - sequenceNumber: 0x50, - destinationPANId: netParams.panId, - destination16: ZigbeeConsts.COORDINATOR_ADDRESS, - source16: parent16, - commandId: undefined, - fcs: 0, - }; - const nwkHeader: ZigbeeNWKHeader = { - frameControl: { - frameType: ZigbeeNWKFrameType.DATA, - protocolVersion: ZigbeeNWKConsts.VERSION_2007, - discoverRoute: ZigbeeNWKRouteDiscovery.SUPPRESS, - multicast: false, - security: true, - sourceRoute: false, - extendedDestination: false, - extendedSource: true, - endDeviceInitiator: false, - }, - destination16: ZigbeeConsts.COORDINATOR_ADDRESS, - source16: parent16, - source64: parent64, - radius: 3, - seqNum: 0x51, - }; - const apsHeader: ZigbeeAPSHeader = { - frameControl: { - frameType: ZigbeeAPSFrameType.CMD, - deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, - ackFormat: false, - security: true, - ackRequest: true, - extendedHeader: false, - }, - counter: 0x52, - }; - - await apsHandler.processCommand(payload, macHeader, nwkHeader, apsHeader); - - expect(frames).toHaveLength(1); - const leaveFrame = decodeMACFramePayload(frames[0]!); - const leaveMacPayload = leaveFrame.buffer.subarray(leaveFrame.payloadOffset, leaveFrame.buffer.length - 2); - const [leaveNwkFrameControl, leaveOffset] = decodeZigbeeNWKFrameControl(leaveMacPayload, 0); - const [leaveNwkHeader, leavePayloadOffset] = decodeZigbeeNWKHeader(leaveMacPayload, leaveOffset, leaveNwkFrameControl); - const leavePayload = decodeZigbeeNWKPayload( - leaveMacPayload, - leavePayloadOffset, - undefined, - context.netParams.eui64, - leaveNwkFrameControl, - leaveNwkHeader, - ); - - expect(leaveNwkFrameControl.frameType).toStrictEqual(ZigbeeNWKFrameType.CMD); - expect(leaveNwkFrameControl.security).toStrictEqual(true); - expect(leaveNwkHeader.destination16).toStrictEqual(child16); - expect(leavePayload.readUInt8(0)).toStrictEqual(ZigbeeNWKCommandId.LEAVE); - expect(leavePayload.readUInt8(1) & ZigbeeNWKConsts.CMD_LEAVE_OPTION_REQUEST).toStrictEqual(ZigbeeNWKConsts.CMD_LEAVE_OPTION_REQUEST); - - expect(context.deviceTable.has(child64)).toStrictEqual(false); - expect(context.address16ToAddress64.has(child16)).toStrictEqual(false); - expect(mockStackContextCallbacks.onDeviceLeft).toHaveBeenCalledWith(child16, child64); - - mockMACHandlerCallbacks.onSendFrame = vi.fn(); - }); }); - /** - * Zigbee Spec 05-3474-23 §4.4.6: APS Request Key Command - * Request key command SHALL allow devices to request keys from TC. - */ describe("APS Request Key Command (Zigbee §4.4.6)", () => { const requester16 = 0x6a6b; const requester64 = 0x00124b00abcdd001n; @@ -1578,39 +1436,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { return { nwkFrameControl, nwkHeader, apsFrameControl, apsHeader, apsPayload }; } - it("encodes request key command payload with partner IEEE address for application link keys", async () => { - const partner64 = 0x00124b00ffffeeddn; - - const frame = await captureMacFrame( - () => apsHandler.sendRequestKey(requester16, ZigbeeAPSConsts.CMD_KEY_APP_MASTER, partner64), - mockMACHandlerCallbacks, - ); - const { nwkFrameControl, apsFrameControl, apsPayload } = decodeRequestKeyFrame(frame); - - expect(nwkFrameControl.security).toStrictEqual(true); - expect(apsFrameControl.security).toStrictEqual(false); - expect(apsFrameControl.frameType).toStrictEqual(ZigbeeAPSFrameType.CMD); - - expect(apsPayload.readUInt8(0)).toStrictEqual(ZigbeeAPSCommandId.REQUEST_KEY); - expect(apsPayload.readUInt8(1)).toStrictEqual(ZigbeeAPSConsts.CMD_KEY_APP_MASTER); - expect(apsPayload.length).toStrictEqual(2 + 8); - expect(apsPayload.readBigUInt64LE(2)).toStrictEqual(partner64); - }); - - it("encodes trust center link key requests without partner address", async () => { - const frame = await captureMacFrame( - () => apsHandler.sendRequestKey(requester16, ZigbeeAPSConsts.CMD_KEY_TC_LINK), - mockMACHandlerCallbacks, - ); - const { nwkFrameControl, apsFrameControl, apsPayload } = decodeRequestKeyFrame(frame); - - expect(nwkFrameControl.security).toStrictEqual(true); - expect(apsFrameControl.frameType).toStrictEqual(ZigbeeAPSFrameType.CMD); - expect(apsPayload.length).toStrictEqual(2); - expect(apsPayload.readUInt8(0)).toStrictEqual(ZigbeeAPSCommandId.REQUEST_KEY); - expect(apsPayload.readUInt8(1)).toStrictEqual(ZigbeeAPSConsts.CMD_KEY_TC_LINK); - }); - it("processes network key requests by unicasting the current network key", async () => { const frames: Buffer[] = []; mockMACHandlerCallbacks.onSendFrame = vi.fn((payload: Buffer) => { @@ -1813,10 +1638,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { }); }); - /** - * Zigbee Spec 05-3474-23 §4.4.7-§4.4.8: APS Verify/Confirm Key Commands - * Verify key response SHALL deliver confirm key status to the requesting device. - */ describe("APS Verify/Confirm Key Commands (Zigbee §4.4.7-§4.4.8)", () => { const device16 = 0x5b5c; const device64 = 0x00124b00deaddeadn; @@ -2106,11 +1927,7 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { }); }); - /** - * Zigbee Spec 05-3474-23 §4.4.7: APS Switch Key Command - * Switch key command SHALL trigger network key update. - */ - describe("APS Switch Key Command (Zigbee §4.4.7)", () => { + describe("APS Switch Key Command (Zigbee §4.4.6)", () => { const router16 = 0x6c6d; const router64 = 0x00124b00abbaaddeen; @@ -2147,46 +1964,22 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { mockMACHandlerCallbacks.onSendFrame = vi.fn(); }); - it("activates pending network key, updates sequence number, and resets frame counter", async () => { - const frames: Buffer[] = []; - mockMACHandlerCallbacks.onSendFrame = vi.fn((payload: Buffer) => { - frames.push(Buffer.from(payload)); - return Promise.resolve(); - }); - + it("ignores switch key commands when no pending key is staged", async () => { const originalKey = Buffer.from(context.netParams.networkKey); - context.netParams.networkKeyFrameCounter = 0x12345678; - context.netParams.networkKeySequenceNumber = 0x02; - - const newKey = Buffer.from("112233445566778899aabbccddeeff00", "hex"); - const newSeq = 0x0b; - const source16 = 0x4a4b; - const source64 = 0x00124b00cfde0001n; - - const transportPayload = Buffer.alloc(1 + 1 + ZigbeeAPSConsts.CMD_KEY_LENGTH + 1 + 8 + 8); - let offset = 0; - transportPayload.writeUInt8(ZigbeeAPSCommandId.TRANSPORT_KEY, offset); - offset += 1; - transportPayload.writeUInt8(ZigbeeAPSConsts.CMD_KEY_STANDARD_NWK, offset); - offset += 1; - newKey.copy(transportPayload, offset); - offset += ZigbeeAPSConsts.CMD_KEY_LENGTH; - transportPayload.writeUInt8(newSeq, offset); - offset += 1; - transportPayload.writeBigUInt64LE(context.netParams.eui64, offset); - offset += 8; - transportPayload.writeBigUInt64LE(source64, offset); + context.netParams.networkKeyFrameCounter = 42; + context.netParams.networkKeySequenceNumber = 0x09; - const transportMac: MACHeader = { + const switchPayload = Buffer.from([ZigbeeAPSCommandId.SWITCH_KEY, 0xaa]); + const macHeader: MACHeader = { frameControl: createMACFrameControl(MACFrameType.DATA, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), - sequenceNumber: 0x70, + sequenceNumber: 0x80, destinationPANId: netParams.panId, destination16: ZigbeeConsts.COORDINATOR_ADDRESS, - source16, + source16: router16, commandId: undefined, fcs: 0, }; - const transportNWK: ZigbeeNWKHeader = { + const nwkHeader: ZigbeeNWKHeader = { frameControl: { frameType: ZigbeeNWKFrameType.DATA, protocolVersion: ZigbeeNWKConsts.VERSION_2007, @@ -2199,12 +1992,12 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { endDeviceInitiator: false, }, destination16: ZigbeeConsts.COORDINATOR_ADDRESS, - source16, - source64, + source16: router16, + source64: router64, radius: 5, - seqNum: 0x71, + seqNum: 0x81, }; - const transportAPS: ZigbeeAPSHeader = { + const apsHeader: ZigbeeAPSHeader = { frameControl: { frameType: ZigbeeAPSFrameType.CMD, deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, @@ -2213,76 +2006,176 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { ackRequest: false, extendedHeader: false, }, - counter: 0x72, + counter: 0x82, }; - await apsHandler.processCommand(transportPayload, transportMac, transportNWK, transportAPS); + await apsHandler.processCommand(switchPayload, macHeader, nwkHeader, apsHeader); - const switchPayload = Buffer.from([ZigbeeAPSCommandId.SWITCH_KEY, newSeq]); - const switchMac: MACHeader = { + expect(context.netParams.networkKey).toStrictEqual(originalKey); + expect(context.netParams.networkKeySequenceNumber).toStrictEqual(0x09); + expect(context.netParams.networkKeyFrameCounter).toStrictEqual(42); + }); + }); + + describe("APS Relay upstream/downstream Command (Zigbee §4.4.11.9-4.4.11.10)", () => { + const unicastDest16 = 2594; + const destination64 = 0xe0797200018e6ebbn; + + beforeEach(() => { + registerNeighborDevice(context, unicastDest16, destination64); + }); + + it("encodes relay downstream command", async () => { + const relayPayload = Buffer.from([0x09, 0x02, 0x01, 0x21]); + const relayHeader: ZigbeeAPSHeader = { + frameControl: { + frameType: ZigbeeAPSFrameType.DATA, + deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, + ackFormat: false, + security: false, + ackRequest: true, + extendedHeader: false, + }, + destEndpoint: 0x00, + clusterId: 0x0045, + profileId: 0x0000, + sourceEndpoint: 0x00, + counter: 9, + }; + const relayAPSFrame = encodeZigbeeAPSFrame(relayHeader, relayPayload); + + const frame = await captureMacFrame( + () => apsHandler.sendRelayMessageDownstream(unicastDest16, undefined, destination64, relayAPSFrame), + mockMACHandlerCallbacks, + ); + const { apsFrameControl, apsPayload } = decodeAPSFrame(frame); + + expect(apsFrameControl.frameType).toStrictEqual(ZigbeeAPSFrameType.CMD); + expect(apsFrameControl.deliveryMode).toStrictEqual(ZigbeeAPSDeliveryMode.UNICAST); + expect(apsFrameControl.security).toStrictEqual(false); + expect(apsFrameControl.ackRequest).toStrictEqual(false); + + expect(apsPayload.readUInt8(0)).toStrictEqual(ZigbeeAPSCommandId.RELAY_MESSAGE_DOWNSTREAM); + expect(apsPayload.readUInt8(1)).toStrictEqual(0x00); + expect(apsPayload.readUInt8(2)).toStrictEqual(8 + relayAPSFrame.length - 1); + + const [, localTlvs] = readZigbeeTlvs(apsPayload, 1); + const relayTlv = localTlvs.get(0x00); + + expect(relayTlv).toBeDefined(); + expect(relayTlv?.readBigUInt64LE(0)).toStrictEqual(destination64); + expect(relayTlv?.subarray(8)).toStrictEqual(relayAPSFrame); + }); + + it("processes relay upstream command", async () => { + const relayPayload = Buffer.from([0xaa, 0xbb, 0xcc]); + const relayHeader: ZigbeeAPSHeader = { + frameControl: { + frameType: ZigbeeAPSFrameType.DATA, + deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, + ackFormat: false, + security: false, + ackRequest: false, + extendedHeader: false, + }, + destEndpoint: 0x00, + clusterId: 0x0045, + profileId: 0x0000, + sourceEndpoint: 0x00, + counter: 18, + }; + const relayAPSFrame = encodeZigbeeAPSFrame(relayHeader, relayPayload); + + const payload = Buffer.allocUnsafe(1 + 2 + 8 + relayAPSFrame.length); + let offset = 0; + offset = payload.writeUInt8(ZigbeeAPSCommandId.RELAY_MESSAGE_UPSTREAM, offset); + offset = payload.writeUInt8(0x00, offset); + offset = payload.writeUInt8(8 + relayAPSFrame.length - 1, offset); + offset = payload.writeBigUInt64LE(destination64, offset); + relayAPSFrame.copy(payload, offset); + + const macHeader: MACHeader = { frameControl: createMACFrameControl(MACFrameType.DATA, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), - sequenceNumber: 0x73, + sequenceNumber: 0x6a, destinationPANId: netParams.panId, destination16: ZigbeeConsts.COORDINATOR_ADDRESS, - source16, + source16: unicastDest16, commandId: undefined, fcs: 0, }; - const switchNWK: ZigbeeNWKHeader = { + const nwkHeader: ZigbeeNWKHeader = { frameControl: { frameType: ZigbeeNWKFrameType.DATA, protocolVersion: ZigbeeNWKConsts.VERSION_2007, discoverRoute: ZigbeeNWKRouteDiscovery.SUPPRESS, multicast: false, - security: false, + security: true, sourceRoute: false, extendedDestination: false, extendedSource: true, endDeviceInitiator: false, }, destination16: ZigbeeConsts.COORDINATOR_ADDRESS, - source16, - source64, + source16: unicastDest16, radius: 5, - seqNum: 0x74, + seqNum: 0x6b, }; - const switchAPS: ZigbeeAPSHeader = { + const apsHeader: ZigbeeAPSHeader = { frameControl: { frameType: ZigbeeAPSFrameType.CMD, deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, ackFormat: false, - security: false, - ackRequest: false, + security: true, + ackRequest: true, extendedHeader: false, }, - counter: 0x75, + counter: 0x6c, }; - await apsHandler.processCommand(switchPayload, switchMac, switchNWK, switchAPS); + await apsHandler.processCommand(payload, macHeader, nwkHeader, apsHeader); - expect(frames).toHaveLength(0); - expect(context.netParams.networkKey).toStrictEqual(newKey); - expect(context.netParams.networkKeySequenceNumber).toStrictEqual(newSeq); - expect(context.netParams.networkKeyFrameCounter).toStrictEqual(0); + expect(mockMACHandlerCallbacks.onSendFrame).not.toHaveBeenCalled(); + }); - mockMACHandlerCallbacks.onSendFrame = vi.fn(); + it("sends relay downstream APS ACK for processed relay upstream command", async () => { + const relayPayload = Buffer.from([0x11, 0x22]); + const relayHeader: ZigbeeAPSHeader = { + frameControl: { + frameType: ZigbeeAPSFrameType.DATA, + deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, + ackFormat: false, + security: false, + ackRequest: true, + extendedHeader: false, + }, + destEndpoint: 0x00, + clusterId: 0x0045, + profileId: 0x0000, + sourceEndpoint: 0x00, + counter: 9, + }; + const relayAPSFrame = encodeZigbeeAPSFrame(relayHeader, relayPayload); - // Ensure the old key is no longer active - expect(context.netParams.networkKey).not.toStrictEqual(originalKey); - }); + const payload = Buffer.allocUnsafe(1 + 2 + 8 + relayAPSFrame.length); + let offset = 0; + offset = payload.writeUInt8(ZigbeeAPSCommandId.RELAY_MESSAGE_UPSTREAM, offset); + offset = payload.writeUInt8(0x00, offset); + offset = payload.writeUInt8(8 + relayAPSFrame.length - 1, offset); + offset = payload.writeBigUInt64LE(destination64, offset); + relayAPSFrame.copy(payload, offset); - it("ignores switch key commands when no pending key is staged", async () => { - const originalKey = Buffer.from(context.netParams.networkKey); - context.netParams.networkKeyFrameCounter = 42; - context.netParams.networkKeySequenceNumber = 0x09; + const frames: Buffer[] = []; + mockMACHandlerCallbacks.onSendFrame = vi.fn((buffer: Buffer) => { + frames.push(Buffer.from(buffer)); + return Promise.resolve(); + }); - const switchPayload = Buffer.from([ZigbeeAPSCommandId.SWITCH_KEY, 0xaa]); const macHeader: MACHeader = { frameControl: createMACFrameControl(MACFrameType.DATA, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), - sequenceNumber: 0x80, + sequenceNumber: 0x6d, destinationPANId: netParams.panId, destination16: ZigbeeConsts.COORDINATOR_ADDRESS, - source16: router16, + source16: unicastDest16, commandId: undefined, fcs: 0, }; @@ -2292,42 +2185,60 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { protocolVersion: ZigbeeNWKConsts.VERSION_2007, discoverRoute: ZigbeeNWKRouteDiscovery.SUPPRESS, multicast: false, - security: false, + security: true, sourceRoute: false, extendedDestination: false, extendedSource: true, endDeviceInitiator: false, }, destination16: ZigbeeConsts.COORDINATOR_ADDRESS, - source16: router16, - source64: router64, + source16: unicastDest16, radius: 5, - seqNum: 0x81, + seqNum: 0x6e, }; const apsHeader: ZigbeeAPSHeader = { frameControl: { frameType: ZigbeeAPSFrameType.CMD, deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, ackFormat: false, - security: false, - ackRequest: false, + security: true, + ackRequest: true, extendedHeader: false, }, - counter: 0x82, + counter: 0x6f, }; - await apsHandler.processCommand(switchPayload, macHeader, nwkHeader, apsHeader); + await apsHandler.processCommand(payload, macHeader, nwkHeader, apsHeader); - expect(context.netParams.networkKey).toStrictEqual(originalKey); - expect(context.netParams.networkKeySequenceNumber).toStrictEqual(0x09); - expect(context.netParams.networkKeyFrameCounter).toStrictEqual(42); + expect(frames).toHaveLength(1); + + const relayDownstream = decodeMACFramePayload(frames[0]!); + const { apsFrameControl, apsPayload } = decodeAPSFrame(relayDownstream); + + expect(apsFrameControl.frameType).toStrictEqual(ZigbeeAPSFrameType.CMD); + expect(apsPayload.readUInt8(0)).toStrictEqual(ZigbeeAPSCommandId.RELAY_MESSAGE_DOWNSTREAM); + + const [, localTlvs] = readZigbeeTlvs(apsPayload, 1); + const relayTlv = localTlvs.get(0x00); + + expect(relayTlv).toBeDefined(); + expect(relayTlv?.readBigUInt64LE(0)).toStrictEqual(destination64); + + const [innerFC, innerOffset] = decodeZigbeeAPSFrameControl(relayTlv!, 8); + const [innerHeader, innerHeaderOffset] = decodeZigbeeAPSHeader(relayTlv!, innerOffset, innerFC); + const innerPayload = decodeZigbeeAPSPayload(relayTlv!, innerHeaderOffset, undefined, context.netParams.eui64, innerFC, innerHeader); + + expect(innerFC.frameType).toStrictEqual(ZigbeeAPSFrameType.ACK); + expect(innerFC.ackRequest).toStrictEqual(false); + expect(innerHeader.counter).toStrictEqual(relayHeader.counter); + expect(innerHeader.destEndpoint).toStrictEqual(relayHeader.sourceEndpoint); + expect(innerHeader.sourceEndpoint).toStrictEqual(relayHeader.destEndpoint); + expect(innerHeader.clusterId).toStrictEqual(relayHeader.clusterId); + expect(innerHeader.profileId).toStrictEqual(relayHeader.profileId); + expect(innerPayload.length).toStrictEqual(0); }); }); - /** - * Zigbee Spec 05-3474-23 §2.2.11: APS Security - * APS security SHALL protect application data using link keys. - */ describe("APS Security (Zigbee §2.2.11)", () => { const child16 = 0x8c8d; const child64 = 0x00124b00ff001122n; @@ -2452,6 +2363,10 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { expect(decoded.apsPayload.subarray(2, 2 + ZigbeeAPSConsts.CMD_KEY_LENGTH)).toStrictEqual(appLinkKey); const initiatorFlagOffset = 2 + ZigbeeAPSConsts.CMD_KEY_LENGTH + 8; expect(decoded.apsPayload.readUInt8(initiatorFlagOffset)).toStrictEqual(1); + // local TLV: Link-Key Features & Capabilities + expect(decoded.apsPayload.readUInt8(initiatorFlagOffset + 1)).toStrictEqual(0x00); + expect(decoded.apsPayload.readUInt8(initiatorFlagOffset + 2)).toStrictEqual(0x00); + expect(decoded.apsPayload.readUInt8(initiatorFlagOffset + 3)).toStrictEqual(0b00000001); }); it("fails to decrypt APS frames when the trust center link key hash is incorrect", async () => { @@ -2490,10 +2405,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { }); }); - /** - * Zigbee Spec 05-3474-23 §2.2.12: Fragmentation - * APS fragmentation SHALL split large payloads across multiple frames. - */ describe("APS Fragmentation (Zigbee §2.2.12)", () => { const fragmentDest16 = 0x4321; const fragmentDest64 = 0x00124b00ffee9911n; @@ -2510,20 +2421,10 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { counter: number, nwkSeqNum: number, ): { - mac: MACHeader; nwk: ZigbeeNWKHeader; aps: ZigbeeAPSHeader; } { return { - mac: { - frameControl: createMACFrameControl(MACFrameType.DATA, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), - sequenceNumber: (0x70 + nwkSeqNum) & 0xff, - destinationPANId: netParams.panId, - destination16: ZigbeeConsts.COORDINATOR_ADDRESS, - source16: fragmentDest16, - commandId: undefined, - fcs: 0, - }, nwk: { frameControl: { frameType: ZigbeeNWKFrameType.DATA, @@ -2586,7 +2487,7 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { for (;;) { const priorCount = frames.length; const ack = buildAck(apsCounter, ackSeq); - await apsHandler.processFrame(Buffer.alloc(0), ack.mac, ack.nwk, ack.aps, lqa); + await apsHandler.resolvePendingAck(ack.nwk, ack.aps); if (frames.length === priorCount) { break; @@ -2730,7 +2631,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { radius: 6, seqNum: (0x90 + block) & 0xff, }; - const inboundAPS: ZigbeeAPSHeader = { frameControl: { frameType: ZigbeeAPSFrameType.DATA, @@ -2774,10 +2674,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { }); }); - /** - * Zigbee Spec 05-3474-23 §2.4: APS Constants - * APS layer SHALL enforce specified constants. - */ describe("APS Constants (Zigbee §2.4)", () => { it("waits apsAckWaitDuration before retrying pending unicast data", async () => { vi.useFakeTimers(); @@ -2817,15 +2713,6 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { const firstTx = decodeAPSFrame(decodeMACFramePayload(sentFrames[0]!)); - const ackMacHeader: MACHeader = { - frameControl: createMACFrameControl(MACFrameType.DATA, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), - sequenceNumber: 0x90, - destinationPANId: netParams.panId, - destination16: ZigbeeConsts.COORDINATOR_ADDRESS, - source16: device16, - commandId: undefined, - fcs: 0, - }; const ackNwkHeader: ZigbeeNWKHeader = { frameControl: { frameType: ZigbeeNWKFrameType.DATA, @@ -2860,7 +2747,7 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { counter: apsCounter, }; - await apsHandler.processFrame(Buffer.alloc(0), ackMacHeader, ackNwkHeader, ackAPSHeader, 0x60); + await apsHandler.resolvePendingAck(ackNwkHeader, ackAPSHeader); await vi.runOnlyPendingTimersAsync(); expect(sentFrames).toHaveLength(2); diff --git a/test/compliance/integration.test.ts b/test/compliance/integration.test.ts index 6d98a7b..488c6ca 100644 --- a/test/compliance/integration.test.ts +++ b/test/compliance/integration.test.ts @@ -72,7 +72,7 @@ import { } from "../../src/zigbee-stack/stack-context.js"; import { NETDEF_EXTENDED_PAN_ID, NETDEF_NETWORK_KEY, NETDEF_PAN_ID, NETDEF_TC_KEY } from "../data.js"; import { createMACFrameControl } from "../utils.js"; -import { decodeMACFramePayload, decodeNwkCommandFromMac, NO_ACK_CODE, registerDevice, registerNeighborDevice } from "./utils.js"; +import { decodeMACFramePayload, decodeNwkFromMac, NO_ACK_CODE, registerDevice, registerNeighborDevice } from "./utils.js"; describe("Integration and End-to-End Compliance", () => { let netParams: NetworkParameters; @@ -182,7 +182,7 @@ describe("Integration and End-to-End Compliance", () => { source64: device64, commandId: MACCommandId.BEACON_REQ, fcs: 0, - } satisfies MACHeader; + }; } function buildAssocHeader(device64: bigint, sequenceNumber = 0x26, source16 = ZigbeeMACConsts.NO_ADDR16): MACHeader { @@ -196,7 +196,7 @@ describe("Integration and End-to-End Compliance", () => { source64: device64, commandId: MACCommandId.ASSOC_REQ, fcs: 0, - } satisfies MACHeader; + }; } function buildDataRequestHeader(device64: bigint, source16: number, sequenceNumber: number): MACHeader { @@ -210,7 +210,7 @@ describe("Integration and End-to-End Compliance", () => { source64: device64, commandId: MACCommandId.DATA_RQ, fcs: 0, - } satisfies MACHeader; + }; } describe("Complete Join Procedure", () => { @@ -291,26 +291,7 @@ describe("Integration and End-to-End Compliance", () => { const indirectQueue = context.indirectTransmissions.get(device64); expect(indirectQueue === undefined || indirectQueue.length === 0).toStrictEqual(true); - // Step 5: Trust Center notifies the network of the unsecured join using Update Device. - const frameCountBeforeUpdate = frames.length; - context.address16ToAddress64.set(ZigbeeConsts.COORDINATOR_ADDRESS, context.netParams.eui64); - const updateSent = await apsHandler.sendUpdateDevice( - ZigbeeConsts.COORDINATOR_ADDRESS, - device64, - assigned16, - ZigbeeAPSConsts.CMD_UPDATE_STANDARD_UNSEC_JOIN, - ); - expect(updateSent).toStrictEqual(true); - expect(frames).toHaveLength(frameCountBeforeUpdate + 1); - const updateDecoded = decodeApsFromMac(frames[frameCountBeforeUpdate]!); - expect(updateDecoded.nwkFrameControl.security).toStrictEqual(true); - expect(updateDecoded.apsFrameControl.security).toStrictEqual(false); - expect(updateDecoded.apsPayload.readUInt8(0)).toStrictEqual(ZigbeeAPSCommandId.UPDATE_DEVICE); - expect(updateDecoded.apsPayload.readBigUInt64LE(1)).toStrictEqual(device64); - expect(updateDecoded.apsPayload.readUInt16LE(9)).toStrictEqual(assigned16); - expect(updateDecoded.apsPayload.readUInt8(11)).toStrictEqual(ZigbeeAPSConsts.CMD_UPDATE_STANDARD_UNSEC_JOIN); - - // Step 6: Device broadcasts an end device announce and the stack reports it to external callbacks. + // Step 5: Device broadcasts an end device announce and the stack reports it to external callbacks. const announcePayload = Buffer.alloc(1 + 2 + 8 + 1); let announceOffset = 0; announcePayload.writeUInt8(0x21, announceOffset); @@ -583,7 +564,7 @@ describe("Integration and End-to-End Compliance", () => { expect(frames.length).toBeGreaterThanOrEqual(2); const discoveryFrames = frames.slice(1); - const decodedDiscovery = discoveryFrames.map((frame) => decodeNwkCommandFromMac(frame, context.netParams.eui64)); + const decodedDiscovery = discoveryFrames.map((frame) => decodeNwkFromMac(frame, context.netParams.eui64)); const routeRequests = decodedDiscovery.filter(({ nwkFrameControl, nwkPayload }) => { if (nwkFrameControl.frameType !== ZigbeeNWKFrameType.CMD) { return false; @@ -707,7 +688,7 @@ describe("Integration and End-to-End Compliance", () => { ); expect(frames).toHaveLength(1); - const { macDecoded, nwkFrameControl, nwkHeader } = decodeNwkCommandFromMac(frames[0]!, context.netParams.eui64); + const { macDecoded, nwkFrameControl, nwkHeader } = decodeNwkFromMac(frames[0]!, context.netParams.eui64); expect(macDecoded.header.destination16).toStrictEqual(relay16); expect(nwkFrameControl.sourceRoute).toStrictEqual(true); expect(nwkHeader.relayAddresses).toStrictEqual([relay16]); @@ -727,7 +708,7 @@ describe("Integration and End-to-End Compliance", () => { await nwkHandler.sendRouteReq(ZigbeeNWKManyToOne.WITH_SOURCE_ROUTING, ZigbeeConsts.BCAST_DEFAULT); expect(frames).toHaveLength(1); - const { macDecoded, nwkFrameControl, nwkHeader, nwkPayload } = decodeNwkCommandFromMac(frames[0]!, context.netParams.eui64); + const { macDecoded, nwkFrameControl, nwkHeader, nwkPayload } = decodeNwkFromMac(frames[0]!, context.netParams.eui64); expect(macDecoded.header.destination16).toStrictEqual(ZigbeeMACConsts.BCAST_ADDR); expect(nwkFrameControl.frameType).toStrictEqual(ZigbeeNWKFrameType.CMD); expect(nwkHeader.destination16).toStrictEqual(ZigbeeConsts.BCAST_DEFAULT); @@ -973,47 +954,8 @@ describe("Integration and End-to-End Compliance", () => { expect(context.netParams.networkKeyFrameCounter).toStrictEqual(nwkCounterStart + 1); const originalKey = Buffer.from(context.netParams.networkKey); - const inboundMacHeader: MACHeader = { - frameControl: createMACFrameControl(MACFrameType.DATA, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), - sequenceNumber: 0x80, - destinationPANId: context.netParams.panId, - destination16: deviceA16, - sourcePANId: context.netParams.panId, - source16: ZigbeeConsts.COORDINATOR_ADDRESS, - source64: context.netParams.eui64, - commandId: undefined, - fcs: 0, - }; - const inboundNwkHeader: ZigbeeNWKHeader = { - frameControl: { - frameType: ZigbeeNWKFrameType.DATA, - protocolVersion: ZigbeeNWKConsts.VERSION_2007, - discoverRoute: ZigbeeNWKRouteDiscovery.SUPPRESS, - multicast: false, - security: true, - sourceRoute: false, - extendedDestination: false, - extendedSource: false, - endDeviceInitiator: false, - }, - destination16: deviceA16, - source16: ZigbeeConsts.COORDINATOR_ADDRESS, - radius: 5, - seqNum: 0x90, - }; - const inboundApsHeader: ZigbeeAPSHeader = { - frameControl: { - frameType: ZigbeeAPSFrameType.CMD, - deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, - ackFormat: false, - security: false, - ackRequest: true, - extendedHeader: false, - }, - counter: 0x99, - }; - await apsHandler.processSwitchKey(Buffer.from([pendingSeq]), 0, inboundMacHeader, inboundNwkHeader, inboundApsHeader); + context.activatePendingNetworkKey(pendingSeq); expect(context.netParams.networkKey.equals(originalKey)).toStrictEqual(false); expect(context.netParams.networkKey).toStrictEqual(pendingKey); @@ -1040,48 +982,7 @@ describe("Integration and End-to-End Compliance", () => { context.setPendingNetworkKey(Buffer.from(nwkKey), 0x33); await apsHandler.sendSwitchKey(device16, 0x33); expect(context.netParams.networkKeyFrameCounter).toStrictEqual(nwkStart + 1); - - const inboundMacHeader: MACHeader = { - frameControl: createMACFrameControl(MACFrameType.DATA, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), - sequenceNumber: 0x55, - destinationPANId: context.netParams.panId, - destination16: device16, - sourcePANId: context.netParams.panId, - source16: ZigbeeConsts.COORDINATOR_ADDRESS, - source64: context.netParams.eui64, - commandId: undefined, - fcs: 0, - }; - const inboundNwkHeader: ZigbeeNWKHeader = { - frameControl: { - frameType: ZigbeeNWKFrameType.DATA, - protocolVersion: ZigbeeNWKConsts.VERSION_2007, - discoverRoute: ZigbeeNWKRouteDiscovery.SUPPRESS, - multicast: false, - security: true, - sourceRoute: false, - extendedDestination: false, - extendedSource: false, - endDeviceInitiator: false, - }, - destination16: device16, - source16: ZigbeeConsts.COORDINATOR_ADDRESS, - radius: 5, - seqNum: 0x56, - }; - const inboundApsHeader: ZigbeeAPSHeader = { - frameControl: { - frameType: ZigbeeAPSFrameType.CMD, - deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, - ackFormat: false, - security: false, - ackRequest: true, - extendedHeader: false, - }, - counter: 0x57, - }; - - await apsHandler.processSwitchKey(Buffer.from([0x33]), 0, inboundMacHeader, inboundNwkHeader, inboundApsHeader); + context.activatePendingNetworkKey(0x33); expect(context.netParams.networkKeyFrameCounter).toStrictEqual(0); }); }); @@ -1242,7 +1143,7 @@ describe("Integration and End-to-End Compliance", () => { await nwkHandler.processCommand(payload, macHeader, nwkHeader); expect(frames).toHaveLength(1); - const decoded = decodeNwkCommandFromMac(frames[0]!, context.netParams.eui64); + const decoded = decodeNwkFromMac(frames[0]!, context.netParams.eui64); const { nwkPayload } = decoded; expect(nwkPayload.readUInt8(0)).toStrictEqual(ZigbeeNWKCommandId.REJOIN_RESP); expect(nwkPayload.readUInt16LE(1)).toStrictEqual(device16); @@ -1309,7 +1210,7 @@ describe("Integration and End-to-End Compliance", () => { await nwkHandler.processCommand(payload, macHeader, nwkHeader); expect(frames).toHaveLength(1); - const decoded = decodeNwkCommandFromMac(frames[0]!, context.netParams.eui64); + const decoded = decodeNwkFromMac(frames[0]!, context.netParams.eui64); expect(decoded.nwkFrameControl.security).toStrictEqual(true); expect(decoded.nwkHeader.securityHeader).not.toBeUndefined(); expect(decoded.nwkHeader.securityHeader!.control.keyId).toStrictEqual(ZigbeeKeyType.NWK); diff --git a/test/compliance/security.test.ts b/test/compliance/security.test.ts index 7933c6c..9b9b663 100644 --- a/test/compliance/security.test.ts +++ b/test/compliance/security.test.ts @@ -1116,64 +1116,6 @@ describe("Zigbee 4.0 Security Compliance", () => { mockMACHandlerCallbacks.onSendFrame = vi.fn(); }); - - it("activates staged network key only after receiving switch key", async () => { - const originalKey = Buffer.from(context.netParams.networkKey); - const originalSeq = context.netParams.networkKeySequenceNumber; - context.netParams.networkKeyFrameCounter = 42; - - const pendingKey = Buffer.from("fedcba98765432100123456789abcdef", "hex"); - const pendingSeq = 0x44; - context.setPendingNetworkKey(pendingKey, pendingSeq); - - expect(context.netParams.networkKey).toStrictEqual(originalKey); - expect(context.netParams.networkKeySequenceNumber).toStrictEqual(originalSeq); - - const payload = Buffer.from([ZigbeeAPSCommandId.SWITCH_KEY, pendingSeq]); - const macHeader: MACHeader = { - frameControl: createMACFrameControl(MACFrameType.DATA, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), - sequenceNumber: 0x40, - destinationPANId: netParams.panId, - destination16: ZigbeeConsts.COORDINATOR_ADDRESS, - source16: 0x1234, - commandId: undefined, - fcs: 0, - }; - const nwkHeader: ZigbeeNWKHeader = { - frameControl: { - frameType: ZigbeeNWKFrameType.DATA, - protocolVersion: ZigbeeNWKConsts.VERSION_2007, - discoverRoute: ZigbeeNWKRouteDiscovery.SUPPRESS, - multicast: false, - security: true, - sourceRoute: false, - extendedDestination: false, - extendedSource: false, - endDeviceInitiator: false, - }, - destination16: ZigbeeConsts.COORDINATOR_ADDRESS, - source16: 0x1234, - radius: 5, - seqNum: 0x41, - }; - const apsHeader: ZigbeeAPSHeader = { - frameControl: { - frameType: ZigbeeAPSFrameType.CMD, - deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, - ackFormat: false, - security: false, - ackRequest: true, - extendedHeader: false, - }, - counter: 0x42, - }; - - await apsHandler.processCommand(payload, macHeader, nwkHeader, apsHeader); - - expect(context.netParams.networkKey).toStrictEqual(pendingKey); - expect(context.netParams.networkKeySequenceNumber).toStrictEqual(pendingSeq); - expect(context.netParams.networkKeyFrameCounter).toStrictEqual(0); - }); }); /** diff --git a/test/compliance/utils.ts b/test/compliance/utils.ts index 147bcd3..1691493 100644 --- a/test/compliance/utils.ts +++ b/test/compliance/utils.ts @@ -14,6 +14,13 @@ import { expect, vi } from "vitest"; import { decodeMACFrameControl, decodeMACHeader, type MACCapabilities } from "../../src/zigbee/mac.js"; +import { + decodeZigbeeAPSFrameControl, + decodeZigbeeAPSHeader, + decodeZigbeeAPSPayload, + type ZigbeeAPSFrameControl, + type ZigbeeAPSHeader, +} from "../../src/zigbee/zigbee-aps.js"; import { decodeZigbeeNWKFrameControl, decodeZigbeeNWKHeader, @@ -47,7 +54,7 @@ export function decodeMACFramePayload(frame: Buffer): DecodedMACFrame { }; } -export function decodeNwkCommandFromMac( +export function decodeNwkFromMac( frame: Buffer, macSource64Fallback: bigint, ): { @@ -65,6 +72,29 @@ export function decodeNwkCommandFromMac( return { macDecoded, nwkFrameControl, nwkHeader, nwkPayload }; } +export function decodeApsFromMac( + frame: Buffer, + macSource64Fallback: bigint, +): { + macDecoded: DecodedMACFrame; + nwkFrameControl: ZigbeeNWKFrameControl; + nwkHeader: ZigbeeNWKHeader; + apsFrameControl: ZigbeeAPSFrameControl; + apsHeader: ZigbeeAPSHeader; + apsPayload: Buffer; +} { + const macDecoded = decodeMACFramePayload(frame); + const macPayload = macDecoded.buffer.subarray(macDecoded.payloadOffset, macDecoded.buffer.length - 2); + const [nwkFrameControl, nwkOffset] = decodeZigbeeNWKFrameControl(macPayload, 0); + const [nwkHeader, nwkPayloadOffset] = decodeZigbeeNWKHeader(macPayload, nwkOffset, nwkFrameControl); + const nwkPayload = decodeZigbeeNWKPayload(macPayload, nwkPayloadOffset, undefined, macSource64Fallback, nwkFrameControl, nwkHeader); + const [apsFrameControl, apsOffset] = decodeZigbeeAPSFrameControl(nwkPayload, 0); + const [apsHeader, apsPayloadOffset] = decodeZigbeeAPSHeader(nwkPayload, apsOffset, apsFrameControl); + const apsPayload = decodeZigbeeAPSPayload(nwkPayload, apsPayloadOffset, undefined, macSource64Fallback, apsFrameControl, apsHeader); + + return { macDecoded, nwkFrameControl, nwkHeader, apsFrameControl, apsHeader, apsPayload }; +} + export async function captureMacFrame(action: () => Promise | unknown, callbacks: MACHandlerCallbacks): Promise { const frames: Buffer[] = []; callbacks.onSendFrame = vi.fn((payload: Buffer) => { @@ -95,6 +125,8 @@ export function registerNeighborDevice(context: StackContext, address16: number, recentLQAs: [], incomingNWKFrameCounter: undefined, endDeviceTimeout: undefined, + lastTransportedNetworkKeySeq: undefined, + linkStatusMisses: undefined, }); context.address16ToAddress64.set(address16, address64); } @@ -108,6 +140,8 @@ export function registerDevice(context: StackContext, address16: number, address recentLQAs: [], incomingNWKFrameCounter: undefined, endDeviceTimeout: undefined, + lastTransportedNetworkKeySeq: undefined, + linkStatusMisses: undefined, }); context.address16ToAddress64.set(address16, address64); } diff --git a/test/drivers/ot-rcp-driver.test.ts b/test/drivers/ot-rcp-driver.test.ts index c8e0baf..0821d25 100644 --- a/test/drivers/ot-rcp-driver.test.ts +++ b/test/drivers/ot-rcp-driver.test.ts @@ -1710,7 +1710,7 @@ describe("OT RCP Driver", () => { it("receives frame NETDEF_ACK_FRAME_TO_COORD", async () => { const onStreamRawFrameSpy = vi.spyOn(driver, "onStreamRawFrame"); const sendACKSpy = vi.spyOn(driver.apsHandler, "sendACK"); - const onZigbeeAPSFrameSpy = vi.spyOn(driver.apsHandler, "processFrame"); + const resolvePendingAckSpy = vi.spyOn(driver.apsHandler, "resolvePendingAck"); const processCommandSpy = vi.spyOn(driver.apsHandler, "processCommand"); driver.parser._transform(makeSpinelStreamRaw(1, NETDEF_ACK_FRAME_TO_COORD), "utf8", () => {}); @@ -1718,7 +1718,7 @@ describe("OT RCP Driver", () => { expect(onStreamRawFrameSpy).toHaveBeenCalledTimes(1); expect(sendACKSpy).toHaveBeenCalledTimes(0); - expect(onZigbeeAPSFrameSpy).toHaveBeenCalledTimes(1); + expect(resolvePendingAckSpy).toHaveBeenCalledTimes(1); expect(processCommandSpy).toHaveBeenCalledTimes(0); }); @@ -2000,7 +2000,6 @@ describe("OT RCP Driver", () => { const onStreamRawFrameSpy = vi.spyOn(driver, "onStreamRawFrame"); const sendACKSpy = vi.spyOn(driver.apsHandler, "sendACK"); const onZigbeeAPSFrameSpy = vi.spyOn(driver.apsHandler, "processFrame"); - const processTransportKeySpy = vi.spyOn(driver.apsHandler, "processTransportKey"); driver.parser._transform(makeSpinelStreamRaw(1, NET2_TRANSPORT_KEY_NWK_FROM_COORD), "utf8", () => {}); await vi.advanceTimersByTimeAsync(10); @@ -2008,7 +2007,6 @@ describe("OT RCP Driver", () => { expect(onStreamRawFrameSpy).toHaveBeenCalledTimes(1); expect(sendACKSpy).toHaveBeenCalledTimes(0); expect(onZigbeeAPSFrameSpy).toHaveBeenCalledTimes(0); - expect(processTransportKeySpy).toHaveBeenCalledTimes(0); }); it("receives frame NET2_REQUEST_KEY_TC_FROM_DEVICE", async () => { diff --git a/test/zigbee-stack/aps-handler.test.ts b/test/zigbee-stack/aps-handler.test.ts index e6ece12..51ee133 100644 --- a/test/zigbee-stack/aps-handler.test.ts +++ b/test/zigbee-stack/aps-handler.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest"; import { logger } from "../../src/utils/logger.js"; import { MACAssociationStatus, type MACHeader } from "../../src/zigbee/mac.js"; +import { GlobalTlv } from "../../src/zigbee/tlvs.js"; import { makeKeyedHashByType, registerDefaultHashedKeys, ZigbeeConsts, ZigbeeKeyType } from "../../src/zigbee/zigbee.js"; import { ZigbeeAPSCommandId, @@ -11,6 +12,7 @@ import { ZigbeeAPSFragmentation, ZigbeeAPSFrameType, type ZigbeeAPSHeader, + ZigbeeAPSUpdateDeviceStatus, } from "../../src/zigbee/zigbee-aps.js"; import { type ZigbeeNWKHeader, ZigbeeNWKRouteDiscovery } from "../../src/zigbee/zigbee-nwk.js"; import { APSHandler, type APSHandlerCallbacks, CONFIG_APS_ACK_WAIT_DURATION_MS } from "../../src/zigbee-stack/aps-handler.js"; @@ -316,33 +318,6 @@ describe("APS Handler", () => { }); describe("APS Command Sending - Device Management", () => { - it("should send Update Device command", async () => { - const destination16 = 0x1234; - const destination64 = 0x00124b0087654321n; - const device64 = 0x00124b0011223344n; - const device16 = 0x5678; - const status = 0x00; // secured rejoin - - // Add device to device table - mockContext.deviceTable.set(destination64, { - address16: destination16, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, - }); - mockContext.address16ToAddress64.set(destination16, destination64); - - const result = await apsHandler.sendUpdateDevice(destination16, device64, device16, status); - - expect(result).toStrictEqual(true); - expect(mockMACHandler.sendFrame).toHaveBeenCalled(); - }); - it("should send Remove Device command", async () => { const destination16 = 0x1234; const destination64 = 0x00124b0087654321n; @@ -370,55 +345,6 @@ describe("APS Handler", () => { }); describe("APS Command Sending - Key Management", () => { - it("should send Request Key command for TC key", async () => { - const destination16 = ZigbeeConsts.COORDINATOR_ADDRESS; - const destination64 = netParams.eui64; - - // Add coordinator to device table - mockContext.deviceTable.set(destination64, { - address16: destination16, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, - }); - mockContext.address16ToAddress64.set(destination16, destination64); - - const result = await apsHandler.sendRequestKey(destination16, 0x04); - - expect(result).toStrictEqual(true); - expect(mockMACHandler.sendFrame).toHaveBeenCalled(); - }); - - it("should send Request Key command for APP key", async () => { - const destination16 = ZigbeeConsts.COORDINATOR_ADDRESS; - const destination64 = netParams.eui64; - const partner64 = 0x00124b0011223344n; - - // Add coordinator to device table - mockContext.deviceTable.set(destination64, { - address16: destination16, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, - }); - mockContext.address16ToAddress64.set(destination16, destination64); - - const result = await apsHandler.sendRequestKey(destination16, 0x02, partner64); - - expect(result).toStrictEqual(true); - expect(mockMACHandler.sendFrame).toHaveBeenCalled(); - }); - it("should send Switch Key command", async () => { const destination16 = 0x1234; const destination64 = 0x00124b0087654321n; @@ -499,80 +425,6 @@ describe("APS Handler", () => { }); describe("APS Command Processing", () => { - it("should process Transport Key command", () => { - const data = Buffer.alloc(50); - let offset = 0; - - // Command ID - data.writeUInt8(0x05, offset++); // TRANSPORT_KEY - - // Key type (Network Key) - data.writeUInt8(0x01, offset++); - - // Key - Buffer.from("0102030405060708090a0b0c0d0e0f10", "hex").copy(data, offset); - offset += 16; - - // Sequence number - data.writeUInt8(0, offset++); - - // Destination address - data.writeBigUInt64LE(0x00124b0012345678n, offset); - - // Source address - data.writeBigUInt64LE(0x00224b0012345678n, offset); - - const macHeader = createMACHeader(); - const nwkHeader = { frameControl: {} } as ZigbeeNWKHeader; - const apsHeader = { frameControl: {} } as ZigbeeAPSHeader; - - // Should not throw - // TODO: more complete tests - expect(() => { - apsHandler.processTransportKey(data, 0, macHeader, nwkHeader, apsHeader); - }).not.toThrow(); - }); - - it("should process Switch Key command", () => { - const data = Buffer.alloc(10); - let offset = 0; - - // Command ID - data.writeUInt8(0x06, offset++); // SWITCH_KEY - - // Sequence number - data.writeUInt8(1, offset++); - - const macHeader = createMACHeader(); - const nwkHeader = { frameControl: {} } as ZigbeeNWKHeader; - const apsHeader = { frameControl: {} } as ZigbeeAPSHeader; - - // Should not throw - // TODO: more complete tests - expect(() => { - apsHandler.processSwitchKey(data, 0, macHeader, nwkHeader, apsHeader); - }).not.toThrow(); - }); - - it("should process Remove Device command", async () => { - const data = Buffer.alloc(20); - let offset = 0; - - // Command ID - data.writeUInt8(0x0b, offset++); // REMOVE_DEVICE - - // Target IEEE address - data.writeBigUInt64LE(0x00124b0011223344n, offset); - - const macHeader = createMACHeader(); - const nwkHeader = { frameControl: {} } as ZigbeeNWKHeader; - const apsHeader = { frameControl: {} } as ZigbeeAPSHeader; - - // Should not throw - // TODO: more complete tests - await expect(apsHandler.processRemoveDevice(data, 0, macHeader, nwkHeader, apsHeader)).resolves.not.toThrow(); - }); - it("should process Tunnel command", () => { const data = Buffer.alloc(30); let offset = 0; @@ -1399,18 +1251,15 @@ describe("APS Handler", () => { }); describe("Update Device Processing", () => { - it("should process update device command", async () => { + it("should process update device command without TLV", async () => { const device16 = 0x1234; const device64 = 0x00124b0012345678n; - const data = Buffer.alloc(12); + const data = Buffer.alloc(11); let offset = 0; - - data.writeBigUInt64LE(device64, offset); - offset += 8; - data.writeUInt16LE(device16, offset); - offset += 2; - data.writeUInt8(0x00, offset); // status = SECURED_REJOIN + offset = data.writeBigUInt64LE(device64, offset); + offset = data.writeUInt16LE(device16, offset); + offset = data.writeUInt8(ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_SECURED_REJOIN, offset); const macHeader = { frameControl: {}, source16: device16 } as MACHeader; const nwkHeader = { frameControl: {}, source16: device16 } as ZigbeeNWKHeader; @@ -1420,6 +1269,37 @@ describe("APS Handler", () => { expect(result).toBe(11); }); + + it("should process update device command with TLV", async () => { + const device16 = 0x1234; + const device64 = 0x00124b0012345678n; + + const data = Buffer.alloc(32); + let offset = 0; + offset = data.writeBigUInt64LE(device64, offset); + offset = data.writeUInt16LE(device16, offset); + offset = data.writeUInt8(ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_UNSECURED_JOIN, offset); + offset = data.writeUInt8(GlobalTlv.JOINER_ENCAPSULATION, offset); + offset = data.writeUInt8(18, offset); + offset = data.writeUInt8(GlobalTlv.FRAGMENTATION_PARAMETERS, offset); + offset = data.writeUInt8(4, offset); + offset = data.writeUInt16LE(device16, offset); // nwkAddress + offset = data.writeUInt8(0x01, offset); // fragmentationOptions + offset = data.writeUInt16LE(0x0052, offset); // maxIncomingTransferUnit + offset = data.writeUInt8(GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS, offset); + offset = data.writeUInt8(9, offset); + offset = data.writeUInt8(0x07, offset); // keyNegotiationProtocolsBitmask + offset = data.writeUInt8(0x06, offset); // preSharedSecretsBitmask + offset = data.writeBigUInt64LE(device64, offset); // sourceDeviceEui64 + + const macHeader = { frameControl: {}, source16: device16 } as MACHeader; + const nwkHeader = { frameControl: {}, source16: device16 } as ZigbeeNWKHeader; + const apsHeader = { frameControl: {} } as ZigbeeAPSHeader; + + const result = await apsHandler.processUpdateDevice(data, 0, macHeader, nwkHeader, apsHeader); + + expect(result).toBe(32); + }); }); it("skips duplicate detection when APS counter is absent", () => { @@ -1966,7 +1846,6 @@ describe("APS Handler", () => { const clearSpy = vi.spyOn(global, "clearTimeout"); - const ackMacHeader = { frameControl: {}, source16: dest16, destination16: ZigbeeConsts.COORDINATOR_ADDRESS } as MACHeader; const ackNwkHeader = { frameControl: {}, source16: dest16, destination16: ZigbeeConsts.COORDINATOR_ADDRESS, seqNum: 0x33 } as ZigbeeNWKHeader; const ackHeader = { frameControl: { @@ -1980,7 +1859,7 @@ describe("APS Handler", () => { counter: apsCounter, } as ZigbeeAPSHeader; - await apsHandler.processFrame(Buffer.alloc(0), ackMacHeader, ackNwkHeader, ackHeader, 180); + await apsHandler.resolvePendingAck(ackNwkHeader, ackHeader); expect(clearSpy).toHaveBeenCalled(); @@ -2023,7 +1902,6 @@ describe("APS Handler", () => { expect(sendFrameMock).toHaveBeenCalledTimes(1); - const ackMacHeader = { frameControl: {}, source16: dest16, destination16: ZigbeeConsts.COORDINATOR_ADDRESS } as MACHeader; const ackNwkHeader = { frameControl: {}, destination16: ZigbeeConsts.COORDINATOR_ADDRESS, @@ -2042,7 +1920,7 @@ describe("APS Handler", () => { counter: apsCounter, } as ZigbeeAPSHeader; - await apsHandler.processFrame(Buffer.alloc(0), ackMacHeader, ackNwkHeader, ackHeader, 175); + await apsHandler.resolvePendingAck(ackNwkHeader, ackHeader); await vi.advanceTimersByTimeAsync(CONFIG_APS_ACK_WAIT_DURATION_MS - 1); await vi.runOnlyPendingTimersAsync(); @@ -2401,83 +2279,4 @@ describe("APS Handler", () => { await apsHandler.processCommand(Buffer.from([0xff]), macHeader, nwkHeader, apsHeader); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("<=x= APS CMD[cmdId=255"), "aps-handler"); }); - - it("parses transport key payload variants", () => { - const macHeader = { frameControl: {}, source16: 0x3001, source64: 0x00124b0033334444n } as MACHeader; - const nwkHeader = { frameControl: {}, source16: 0x3001, source64: 0x00124b0033334444n } as ZigbeeNWKHeader; - const apsHeader = { frameControl: {} } as ZigbeeAPSHeader; - - const tcPayload = Buffer.alloc(1 + 16 + 8 + 8); - tcPayload.writeUInt8(ZigbeeAPSConsts.CMD_KEY_TC_LINK, 0); - Buffer.alloc(16, 0xaa).copy(tcPayload, 1); - tcPayload.writeBigUInt64LE(0x00124b00aaaa0001n, 17); - tcPayload.writeBigUInt64LE(0x00124b00bbbb0002n, 25); - const tcOffset = apsHandler.processTransportKey(tcPayload, 0, macHeader, nwkHeader, apsHeader); - expect(tcOffset).toStrictEqual(tcPayload.length); - - const appPayload = Buffer.alloc(1 + 16 + 8 + 1); - appPayload.writeUInt8(ZigbeeAPSConsts.CMD_KEY_APP_MASTER, 0); - Buffer.alloc(16, 0xbb).copy(appPayload, 1); - appPayload.writeBigUInt64LE(0x00124b00cccc0003n, 17); - appPayload.writeUInt8(0x01, 25); - const appOffset = apsHandler.processTransportKey(appPayload, 0, macHeader, nwkHeader, apsHeader); - expect(appOffset).toStrictEqual(appPayload.length); - }); - - it("handles remove device outcomes", async () => { - const target64 = 0x00124b0044445555n; - mockContext.deviceTable.set(target64, { - address16: 0x5566, - capabilities: undefined, - authorized: true, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, - }); - - const leaveSpy = vi.spyOn(mockNWKHandler, "sendLeave").mockResolvedValueOnce(false); - const warnSpy = vi.spyOn(logger, "warning"); - - const payload = Buffer.alloc(1 + 8); - payload.writeUInt8(ZigbeeAPSCommandId.REMOVE_DEVICE, 0); - payload.writeBigUInt64LE(target64, 1); - - await apsHandler.processRemoveDevice( - payload, - 1, - { frameControl: {}, source16: 0x5566 } as MACHeader, - { - frameControl: {}, - source16: 0x5566, - } as ZigbeeNWKHeader, - {} as ZigbeeAPSHeader, - ); - - expect(leaveSpy).toHaveBeenCalledWith(0x5566, false); - expect(mockContext.disassociate).toHaveBeenCalledWith(0x5566, target64); - - // Unknown device branch - const unknownPayload = Buffer.alloc(1 + 8); - unknownPayload.writeUInt8(ZigbeeAPSCommandId.REMOVE_DEVICE, 0); - unknownPayload.writeBigUInt64LE(0x00124b0055556666n, 1); - - await apsHandler.processRemoveDevice( - unknownPayload, - 1, - { frameControl: {}, source16: 0x1111 } as MACHeader, - { - frameControl: {}, - source16: 0x1111, - } as ZigbeeNWKHeader, - {} as ZigbeeAPSHeader, - ); - - expect(warnSpy).toHaveBeenCalled(); - - leaveSpy.mockRestore(); - warnSpy.mockRestore(); - }); }); diff --git a/test/zigbee/tlvs.test.ts b/test/zigbee/tlvs.test.ts index 7438506..b743d37 100644 --- a/test/zigbee/tlvs.test.ts +++ b/test/zigbee/tlvs.test.ts @@ -111,21 +111,23 @@ describe("Zigbee TLVs", () => { const joinerNested = makeTlv(GlobalTlv.CONFIGURATION_PARAMETERS, Buffer.from([0xaa, 0x55])); const beaconNested = makeTlv(GlobalTlv.DEVICE_CAPABILITY_EXTENSION, Buffer.from([0x10, 0x00])); const bdbNested = makeTlv(GlobalTlv.PAN_ID_CONFLICT_REPORT, Buffer.from([0x01, 0x00])); + const localNested = makeTlv(0x00, Buffer.from([0x03, 0x04])); const joiner = makeTlv(GlobalTlv.JOINER_ENCAPSULATION, joinerNested); - const beacon = makeTlv(GlobalTlv.BEACON_APPENDIX_ENCAPSULATION, beaconNested); + const beacon = makeTlv(GlobalTlv.BEACON_APPENDIX_ENCAPSULATION, Buffer.concat([localNested, beaconNested])); const bdb = makeTlv(GlobalTlv.BDB_ENCAPSULATION, bdbNested); const data = Buffer.concat([joiner, beacon, bdb]); const [globalTlvs] = readZigbeeTlvs(data, 0); - expect(globalTlvs[GlobalTlv.JOINER_ENCAPSULATION]?.additionalTLVs[GlobalTlv.CONFIGURATION_PARAMETERS]).toStrictEqual({ + expect(globalTlvs[GlobalTlv.JOINER_ENCAPSULATION]?.additionalTlvs[GlobalTlv.CONFIGURATION_PARAMETERS]).toStrictEqual({ parameters: 0x55aa, }); - expect(globalTlvs[GlobalTlv.BEACON_APPENDIX_ENCAPSULATION]?.additionalTLVs[GlobalTlv.DEVICE_CAPABILITY_EXTENSION]).toStrictEqual({ + expect(globalTlvs[GlobalTlv.BEACON_APPENDIX_ENCAPSULATION]?.additionalTlvs[GlobalTlv.DEVICE_CAPABILITY_EXTENSION]).toStrictEqual({ capabilityExtension: 0x0010, }); - expect(globalTlvs[GlobalTlv.BDB_ENCAPSULATION]?.additionalTLVs[GlobalTlv.PAN_ID_CONFLICT_REPORT]).toStrictEqual({ + expect(globalTlvs[GlobalTlv.BEACON_APPENDIX_ENCAPSULATION]?.additionalLocalTlvs.get(0x00)).toStrictEqual(Buffer.from([0x03, 0x04])); + expect(globalTlvs[GlobalTlv.BDB_ENCAPSULATION]?.additionalTlvs[GlobalTlv.PAN_ID_CONFLICT_REPORT]).toStrictEqual({ nwkPanIdConflictCount: 0x0001, }); }); From e29a471b42e7c789f10308af0b175903e5d91e60 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:10:19 +0100 Subject: [PATCH 3/9] feat: MAC beacon TLVs --- src/utils/types.ts | 1 + src/zigbee-stack/mac-handler.ts | 25 +-- src/zigbee/mac.ts | 38 ++++- src/zigbee/tlvs.ts | 228 +++++++++++++++++++------- test/compliance/mac.test.ts | 52 +++++- test/zigbee-stack/mac-handler.test.ts | 47 ++++-- test/zigbee/enc-dec.test.ts | 5 +- 7 files changed, 303 insertions(+), 93 deletions(-) create mode 100644 src/utils/types.ts diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000..1dfad62 --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1 @@ +export type RequiredNonNullable = { [P in keyof T]-?: NonNullable }; diff --git a/src/zigbee-stack/mac-handler.ts b/src/zigbee-stack/mac-handler.ts index dfafc6d..a849976 100644 --- a/src/zigbee-stack/mac-handler.ts +++ b/src/zigbee-stack/mac-handler.ts @@ -494,17 +494,20 @@ export class MACHandler { pendAddr: {}, fcs: 0, }, - encodeMACZigbeeBeacon({ - protocolId: ZigbeeMACConsts.ZIGBEE_BEACON_PROTOCOL_ID, - profile: 0x2, // Zigbee PRO - version: ZigbeeNWKConsts.VERSION_2007, - routerCapacity: true, - deviceDepth: 0, // coordinator - endDeviceCapacity: true, - extendedPANId: this.#context.netParams.extendedPanId, - txOffset: 0xffffff, // XXX: value from sniffed frames - updateId: this.#context.netParams.nwkUpdateId, - }), + encodeMACZigbeeBeacon( + { + protocolId: ZigbeeMACConsts.ZIGBEE_BEACON_PROTOCOL_ID, + profile: 0x2, // Zigbee PRO + version: ZigbeeNWKConsts.VERSION_2007, + routerCapacity: true, + deviceDepth: 0, // coordinator + endDeviceCapacity: true, + extendedPANId: this.#context.netParams.extendedPanId, + txOffset: 0xffffff, // XXX: value from sniffed frames + updateId: this.#context.netParams.nwkUpdateId, + }, + this.#context.netParams.eui64, + ), ); logger.debug(() => `===> MAC BEACON[seqNum=${macSeqNum}]`, NS); diff --git a/src/zigbee/mac.ts b/src/zigbee/mac.ts index 0e4360b..6ec0f29 100644 --- a/src/zigbee/mac.ts +++ b/src/zigbee/mac.ts @@ -1,3 +1,12 @@ +import { + readZigbeeTlvs, + writeZigbeeTlvFragmentationParameters, + writeZigbeeTlvRouterInformation, + writeZigbeeTlvSupportedKeyNegotiationMethods, + type ZigbeeGlobalTlvs, +} from "./tlvs"; +import { ZigbeeConsts } from "./zigbee"; + /** * const enum with sole purpose of avoiding "magic numbers" in code for well-known values */ @@ -1212,6 +1221,8 @@ export type MACZigbeeBeacon = { /** The time difference between a device and its parent's beacon. */ txOffset: number; updateId: number; + globalTlvs: ZigbeeGlobalTlvs; + localTlvs: Map; }; /** @@ -1220,7 +1231,6 @@ export type MACZigbeeBeacon = { * SPEC COMPLIANCE NOTES: * - ✅ Parses routing and end-device capacity bits for join admission logic * - ✅ Retains Update ID for network parameter synchronization - * - ⚠️ Exposes protocolId even though Zigbee fixes it to zero for diagnostics * DEVICE SCOPE: Beacon receivers (all logical devices) */ export function decodeMACZigbeeBeacon(data: Buffer, offset: number): MACZigbeeBeacon { @@ -1239,8 +1249,10 @@ export function decodeMACZigbeeBeacon(data: Buffer, offset: number): MACZigbeeBe const extendedPANId = data.readBigUInt64LE(offset); offset += 8; const endBytes = data.readUInt32LE(offset); + offset += 4; const txOffset = endBytes & ZigbeeMACConsts.ZIGBEE_BEACON_TX_OFFSET_MASK; const updateId = (endBytes & ZigbeeMACConsts.ZIGBEE_BEACON_UPDATE_ID_MASK) >> ZigbeeMACConsts.ZIGBEE_BEACON_UPDATE_ID_SHIFT; + const [globalTlvs, localTlvs] = readZigbeeTlvs(data, offset); return { protocolId, @@ -1252,6 +1264,8 @@ export function decodeMACZigbeeBeacon(data: Buffer, offset: number): MACZigbeeBe extendedPANId, txOffset, updateId, + globalTlvs, + localTlvs, }; } @@ -1261,11 +1275,10 @@ export function decodeMACZigbeeBeacon(data: Buffer, offset: number): MACZigbeeBe * SPEC COMPLIANCE NOTES: * - ✅ Serialises Zigbee beacon descriptor using mandated masks and shifts * - ✅ Hardcodes protocol ID to zero per Zigbee specification - * - ⚠️ Relies on caller to enforce txOffset bounds defined by aMaxBeaconTxOffset * DEVICE SCOPE: Beacon transmitters (coordinator/router) */ -export function encodeMACZigbeeBeacon(beacon: MACZigbeeBeacon): Buffer { - const payload = Buffer.allocUnsafe(ZigbeeMACConsts.ZIGBEE_BEACON_LENGTH); +export function encodeMACZigbeeBeacon(beacon: Omit, eui64: bigint): Buffer { + const payload = Buffer.allocUnsafe(ZigbeeMACConsts.ZIGBEE_BEACON_LENGTH + 23 /* tlvs */); let offset = 0; offset = payload.writeUInt8(0, offset); // protocol ID always 0 on Zigbee beacons offset = payload.writeUInt16LE( @@ -1284,6 +1297,23 @@ export function encodeMACZigbeeBeacon(beacon: MACZigbeeBeacon): Buffer { ((beacon.updateId << ZigbeeMACConsts.ZIGBEE_BEACON_UPDATE_ID_SHIFT) & ZigbeeMACConsts.ZIGBEE_BEACON_UPDATE_ID_MASK), offset, ); + // R23 TLVs + offset = writeZigbeeTlvSupportedKeyNegotiationMethods(payload, offset, { + keyNegotiationProtocolsBitmask: 0b111, + // Install Code Key, Passcode Key + preSharedSecretsBitmask: 0b00000110, + sourceDeviceEui64: eui64, + }); + offset = writeZigbeeTlvFragmentationParameters(payload, offset, { + nwkAddress: ZigbeeConsts.COORDINATOR_ADDRESS, + fragmentationOptions: 0b1, + maxIncomingTransferUnit: ZigbeeMACConsts.FRAME_MAX_SIZE, + }); + const uptimeOver24h = performance.now() > 86_400_000; + // Hub Connectivity, Preferred Parent, Enhanced Beacon Request Support, + // MAC Data Poll Keepalive Support, End Device Keepalive Support, Power Negotiation Support + // TODO: add support for user-configurable "Battery Backup" bit + offset = writeZigbeeTlvRouterInformation(payload, offset, { bitmap: uptimeOver24h ? 0b11110111 : 0b11110101 }); return payload; } diff --git a/src/zigbee/tlvs.ts b/src/zigbee/tlvs.ts index fa2fc01..ebe8436 100644 --- a/src/zigbee/tlvs.ts +++ b/src/zigbee/tlvs.ts @@ -1,3 +1,4 @@ +import type { RequiredNonNullable } from "../utils/types.js"; import { ZigbeeConsts } from "./zigbee.js"; export const enum GlobalTlv { @@ -26,69 +27,138 @@ export const enum GlobalTlv { // Reserved = 77-255 } +type GlobalTlvEncapsulated = { + additionalTlvs: ZigbeeGlobalTlvs; + additionalLocalTlvs: Map; +}; + +type GlobalTlvManufacturerSpecific = { + /** uint16 */ + zigbeeManufacturerId: number; + /** variable */ + additionalData: Buffer; +}; + +type GlobalTlvSupportedKeyNegotiatioMethods = { + /** + * uint8 + * - 0: Static Key Request (Zigbee 3.0 Mechanism, TCLK procedure) + * - 1: SPEKE using Curve25519 with Hash AES-MMO-128 + * - 2: SPEKE using Curve25519 with Hash SHA-256 + * - 3–7: Reserved + */ + keyNegotiationProtocolsBitmask: number; + /** + * uint8 + * - 0: Symmetric Authentication Token + * - This is a token unique to the Trust Center and network that the device is running on, and is assigned by the Trust center after joining. + * The token is used to renegotiate a link key using the Key Negotiation protocol and is good for the life of the device on the network. + * - 1: Install Code Key + * - 128-bit pre-configured link-key derived from install code + * - 2: Passcode Key + * - A variable length passcode for PAKE protocols. This passcode can be shorter for easy entry by a user. + * - 3: Basic Access Key + * - This key is used by other Zigbee specifications for joining with an alternate pre-shared secret. The definition and usage is defined by those specifications. The usage is optional by the core Zigbee specification. + * - 4: Administrative Access Key + * - This key is used by other Zigbee specifications for joining with an alternate pre-shared secret. The definition and usage is defined by those specifications. The usage is optional by the core Zigbee specification. + * - 5-7: Reserved + */ + preSharedSecretsBitmask: number; + sourceDeviceEui64: bigint | undefined; +}; + +type GlobalTlvPanIdConflictReport = { + /** uint16 */ + nwkPanIdConflictCount: number; +}; + +type GlobalTlvNextPanId = { + /** uint16 */ + panId: number; +}; + +type GlobalTlvNextChannelChange = { + /** uint32 */ + channel: number; +}; + +type GlobalTlvSummetricPassphrase = { + /** 16-byte */ + passphrase: Buffer; +}; + +type GlobalTlvRouterInformation = { + /** + * uint16 + * - 0: Hub Connectivity + * - This bit indicates the state of nwkHubConnectivity from the NIB of the local device. + * It advertises whether the router has connectivity to a Hub device as defined by the higher-level application layer. + * A value of 1 means there is connectivity, and avalue of 0 means there is no current Hub connectivity. + * - 1: Uptime + * - This 1-bit value indicates the uptime of the router. + * A value of 1 indicates the router has been up for more than 24 hours. + * A value of 0 indicates the router has been up for less than 24 hours. + * - 2: Preferred Parent + * - This bit indicates the state of nwkPreferredParent from the NIB of the local device. + * When supported, it extends Hub Connecivity, advertising the devices capacity to be the parent for an additional device. + * A value of 1 means that this device should be preferred. + * A value of 0 indicates that it should not be preferred. + * Devices that do not make this determination SHALL always report a value of 0. + * - 3: Battery Backup + * - This bit indicates that the router has battery backup and thus will not be affected by temporary losses in power. + * - 4: Enhanced Beacon Request Support + * - When this bit is set to 1, it indicates that the router supports responding to Enhanced beacon requests as defined by IEEE Std 802.15.4. + * A zero for this bit indicates the device has no support for responding to enhanced beacon requests. + * - 5: MAC Data Poll Keepalive Support + * - This indicates that the device has support for the MAC Data Poll Keepalive method for End Device timeouts. + * - 6: End Device Keepalive Support + * - This indicates that the device has support for the End Device Keepalive method for End Device timeouts. + * - 7: Power Negotiation Support + * - This indicates the device has support for Power Negotiation with end devices. + * - 8-15: Reserved + * - These bits SHALL be set to 0. + */ + bitmap: number; +}; + +type GlobalTlvFragmentationParameters = { + /** uint16 */ + nwkAddress: number; + /** + * uint8 + * - Bit 0 = Application Fragmentation Supported (mirrors AIB attribute 0xd5, apsApplicationFragmentationSupport). + * - Bit 1-7 = Reserved for future use + */ + fragmentationOptions: number | undefined; + /** uint16 */ + maxIncomingTransferUnit: number | undefined; +}; + +type GlobalTlvConfigurationParameters = { + /** uint16 */ + parameters: number; +}; + +type GlobalTlvDeviceCapabilityExtension = { + /** uint16 */ + capabilityExtension: number; +}; + export type ZigbeeGlobalTlvs = { /** Should be ignored if unknown */ - [GlobalTlv.MANUFACTURER_SPECIFIC]?: { - /** uint16 */ - zigbeeManufacturerId: number; - /** variable */ - additionalData: Buffer; - }; - [GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS]?: { - /** uint8 */ - keyNegotiationProtocolsBitmask: number; - /** uint8 */ - preSharedSecretsBitmask: number; - sourceDeviceEui64: bigint | undefined; - }; - [GlobalTlv.PAN_ID_CONFLICT_REPORT]?: { - /** uint16 */ - nwkPanIdConflictCount: number; - }; - [GlobalTlv.NEXT_PAN_ID]?: { - /** uint16 */ - panId: number; - }; - [GlobalTlv.NEXT_CHANNEL_CHANGE]?: { - /** uint32 */ - channel: number; - }; - [GlobalTlv.SYMMETRIC_PASSPHRASE]?: { - /** 16-byte */ - passphrase: Buffer; - }; - [GlobalTlv.ROUTER_INFORMATION]?: { - /** uint16 */ - bitmap: number; - }; - [GlobalTlv.FRAGMENTATION_PARAMETERS]?: { - /** uint16 */ - nwkAddress: number; - /** uint8 */ - fragmentationOptions: number | undefined; - /** uint16 */ - maxIncomingTransferUnit: number | undefined; - }; - [GlobalTlv.JOINER_ENCAPSULATION]?: { - additionalTlvs: ZigbeeGlobalTlvs; - additionalLocalTlvs: Map; - }; - [GlobalTlv.BEACON_APPENDIX_ENCAPSULATION]?: { - additionalTlvs: ZigbeeGlobalTlvs; - additionalLocalTlvs: Map; - }; - [GlobalTlv.BDB_ENCAPSULATION]?: { - additionalTlvs: ZigbeeGlobalTlvs; - additionalLocalTlvs: Map; - }; - [GlobalTlv.CONFIGURATION_PARAMETERS]?: { - /** uint16 */ - parameters: number; - }; - [GlobalTlv.DEVICE_CAPABILITY_EXTENSION]?: { - /** uint16 */ - capabilityExtension: number; - }; + [GlobalTlv.MANUFACTURER_SPECIFIC]?: GlobalTlvManufacturerSpecific; + [GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS]?: GlobalTlvSupportedKeyNegotiatioMethods; + [GlobalTlv.PAN_ID_CONFLICT_REPORT]?: GlobalTlvPanIdConflictReport; + [GlobalTlv.NEXT_PAN_ID]?: GlobalTlvNextPanId; + [GlobalTlv.NEXT_CHANNEL_CHANGE]?: GlobalTlvNextChannelChange; + [GlobalTlv.SYMMETRIC_PASSPHRASE]?: GlobalTlvSummetricPassphrase; + [GlobalTlv.ROUTER_INFORMATION]?: GlobalTlvRouterInformation; + [GlobalTlv.FRAGMENTATION_PARAMETERS]?: GlobalTlvFragmentationParameters; + [GlobalTlv.JOINER_ENCAPSULATION]?: GlobalTlvEncapsulated; + [GlobalTlv.BEACON_APPENDIX_ENCAPSULATION]?: GlobalTlvEncapsulated; + [GlobalTlv.BDB_ENCAPSULATION]?: GlobalTlvEncapsulated; + [GlobalTlv.CONFIGURATION_PARAMETERS]?: GlobalTlvConfigurationParameters; + [GlobalTlv.DEVICE_CAPABILITY_EXTENSION]?: GlobalTlvDeviceCapabilityExtension; }; /** @@ -329,3 +399,39 @@ export function readZigbeeTlvs(data: Buffer, offset: number, parent?: number): [ return [globalTlvs, localTlvs, offset]; } + +export function writeZigbeeTlvSupportedKeyNegotiationMethods( + data: Buffer, + offset: number, + tlv: RequiredNonNullable, +): number { + offset = data.writeUInt8(GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS, offset); + offset = data.writeUInt8(9, offset); // per spec, actual data length is `length field + 1` + offset = data.writeUInt8(tlv.keyNegotiationProtocolsBitmask, offset); + offset = data.writeUInt8(tlv.preSharedSecretsBitmask, offset); + offset = data.writeBigUInt64LE(tlv.sourceDeviceEui64, offset); + + return offset; +} + +export function writeZigbeeTlvFragmentationParameters( + data: Buffer, + offset: number, + tlv: RequiredNonNullable, +): number { + offset = data.writeUInt8(GlobalTlv.FRAGMENTATION_PARAMETERS, offset); + offset = data.writeUInt8(4, offset); // per spec, actual data length is `length field + 1` + offset = data.writeUInt16LE(tlv.nwkAddress, offset); + offset = data.writeUInt8(tlv.fragmentationOptions, offset); + offset = data.writeUInt16LE(tlv.maxIncomingTransferUnit, offset); + + return offset; +} + +export function writeZigbeeTlvRouterInformation(data: Buffer, offset: number, tlv: RequiredNonNullable): number { + offset = data.writeUInt8(GlobalTlv.ROUTER_INFORMATION, offset); + offset = data.writeUInt8(1, offset); // per spec, actual data length is `length field + 1` + offset = data.writeUInt16LE(tlv.bitmap, offset); + + return offset; +} diff --git a/test/compliance/mac.test.ts b/test/compliance/mac.test.ts index d614534..d16fada 100644 --- a/test/compliance/mac.test.ts +++ b/test/compliance/mac.test.ts @@ -31,6 +31,7 @@ import { MACSecurityLevel, ZigbeeMACConsts, } from "../../src/zigbee/mac.js"; +import { GlobalTlv } from "../../src/zigbee/tlvs.js"; import { makeKeyedHashByType, registerDefaultHashedKeys, ZigbeeConsts, ZigbeeKeyType } from "../../src/zigbee/zigbee.js"; import { decodeZigbeeAPSFrameControl, decodeZigbeeAPSHeader, ZigbeeAPSDeliveryMode, ZigbeeAPSFrameType } from "../../src/zigbee/zigbee-aps.js"; import { @@ -677,7 +678,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { it("encodes Zigbee beacon payload with coordinator capabilities", async () => { const beacon = await generateBeacon(); - const payload = decodeMACZigbeeBeacon(beacon.buffer, beacon.payloadOffset); + const payload = decodeMACZigbeeBeacon(beacon.buffer.subarray(0, -2), beacon.payloadOffset); expect(payload.protocolId).toStrictEqual(ZigbeeMACConsts.ZIGBEE_BEACON_PROTOCOL_ID); expect(payload.profile).toStrictEqual(0x02); @@ -688,6 +689,55 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { expect(payload.extendedPANId).toStrictEqual(netParams.extendedPanId); expect(payload.txOffset).toStrictEqual(0x00ffffff); expect(payload.updateId).toStrictEqual(netParams.nwkUpdateId); + expect(payload.globalTlvs).toStrictEqual({ + [GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS]: { + keyNegotiationProtocolsBitmask: 0b111, + preSharedSecretsBitmask: 0b110, + sourceDeviceEui64: 0x00124b0012345678n, + }, + [GlobalTlv.FRAGMENTATION_PARAMETERS]: { + nwkAddress: ZigbeeConsts.COORDINATOR_ADDRESS, + fragmentationOptions: 0b1, + maxIncomingTransferUnit: ZigbeeMACConsts.FRAME_MAX_SIZE, + }, + [GlobalTlv.ROUTER_INFORMATION]: { + bitmap: 0b11110101, + }, + }); + expect(payload.localTlvs.size).toStrictEqual(0); + }); + + it("encodes Zigbee beacon payload with uptime in TLV", async () => { + const nowSpy = vi.spyOn(performance, "now").mockReturnValueOnce(86_400_001); + const beacon = await generateBeacon(); + const payload = decodeMACZigbeeBeacon(beacon.buffer.subarray(0, -2), beacon.payloadOffset); + + expect(payload.protocolId).toStrictEqual(ZigbeeMACConsts.ZIGBEE_BEACON_PROTOCOL_ID); + expect(payload.profile).toStrictEqual(0x02); + expect(payload.version).toStrictEqual(ZigbeeNWKConsts.VERSION_2007); + expect(payload.routerCapacity).toStrictEqual(true); + expect(payload.deviceDepth).toStrictEqual(0); + expect(payload.endDeviceCapacity).toStrictEqual(true); + expect(payload.extendedPANId).toStrictEqual(netParams.extendedPanId); + expect(payload.txOffset).toStrictEqual(0x00ffffff); + expect(payload.globalTlvs).toStrictEqual({ + [GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS]: { + keyNegotiationProtocolsBitmask: 0b111, + preSharedSecretsBitmask: 0b110, + sourceDeviceEui64: 0x00124b0012345678n, + }, + [GlobalTlv.FRAGMENTATION_PARAMETERS]: { + nwkAddress: ZigbeeConsts.COORDINATOR_ADDRESS, + fragmentationOptions: 0b1, + maxIncomingTransferUnit: ZigbeeMACConsts.FRAME_MAX_SIZE, + }, + [GlobalTlv.ROUTER_INFORMATION]: { + bitmap: 0b11110111, + }, + }); + expect(payload.localTlvs.size).toStrictEqual(0); + + nowSpy.mockRestore(); }); }); diff --git a/test/zigbee-stack/mac-handler.test.ts b/test/zigbee-stack/mac-handler.test.ts index 2cb6795..169768d 100644 --- a/test/zigbee-stack/mac-handler.test.ts +++ b/test/zigbee-stack/mac-handler.test.ts @@ -21,9 +21,9 @@ import { MACSecurityKeyIdMode, MACSecurityLevel, type MACSuperframeSpec, - type MACZigbeeBeacon, ZigbeeMACConsts, } from "../../src/zigbee/mac.js"; +import { GlobalTlv } from "../../src/zigbee/tlvs.js"; import { ZigbeeConsts } from "../../src/zigbee/zigbee.js"; import { ZigbeeNWKConsts } from "../../src/zigbee/zigbee-nwk.js"; import { MACHandler, type MACHandlerCallbacks } from "../../src/zigbee-stack/mac-handler.js"; @@ -1073,24 +1073,41 @@ describe("MACHandler", () => { }); it("encodes Zigbee beacon capacity flags", () => { - const beacon: MACZigbeeBeacon = { - protocolId: ZigbeeMACConsts.ZIGBEE_BEACON_PROTOCOL_ID, - profile: 0x02, - version: ZigbeeNWKConsts.VERSION_2007, - routerCapacity: false, - deviceDepth: 0x03, - endDeviceCapacity: false, - extendedPANId: 0x00124b0000000011n, - txOffset: 0x000123, - updateId: 0x09, - }; - - const encoded = encodeMACZigbeeBeacon(beacon); + const encoded = encodeMACZigbeeBeacon( + { + protocolId: ZigbeeMACConsts.ZIGBEE_BEACON_PROTOCOL_ID, + profile: 0x02, + version: ZigbeeNWKConsts.VERSION_2007, + routerCapacity: false, + deviceDepth: 0x03, + endDeviceCapacity: false, + extendedPANId: 0x00124b0000000011n, + txOffset: 0x000123, + updateId: 0x09, + }, + mockContext.netParams.eui64, + ); const decoded = decodeMACZigbeeBeacon(encoded, 0); expect(decoded.routerCapacity).toBe(false); expect(decoded.endDeviceCapacity).toBe(false); - expect(decoded.deviceDepth).toStrictEqual(beacon.deviceDepth); + expect(decoded.deviceDepth).toStrictEqual(0x03); + expect(decoded.globalTlvs).toStrictEqual({ + [GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS]: { + keyNegotiationProtocolsBitmask: 0b111, + preSharedSecretsBitmask: 0b110, + sourceDeviceEui64: 0x00124b0012345678n, + }, + [GlobalTlv.FRAGMENTATION_PARAMETERS]: { + nwkAddress: ZigbeeConsts.COORDINATOR_ADDRESS, + fragmentationOptions: 0b1, + maxIncomingTransferUnit: ZigbeeMACConsts.FRAME_MAX_SIZE, + }, + [GlobalTlv.ROUTER_INFORMATION]: { + bitmap: 0b11110101, + }, + }); + expect(decoded.localTlvs.size).toStrictEqual(0); }); it("computes MIC length per security level", () => { diff --git a/test/zigbee/enc-dec.test.ts b/test/zigbee/enc-dec.test.ts index de55647..e1c7eb0 100644 --- a/test/zigbee/enc-dec.test.ts +++ b/test/zigbee/enc-dec.test.ts @@ -12,6 +12,7 @@ import { type MACCapabilities, type MACHeader, type MACZigbeeBeacon, + ZigbeeMACConsts, } from "../../src/zigbee/mac.js"; import { makeKeyedHashByType, registerDefaultHashedKeys, ZigbeeKeyType } from "../../src/zigbee/zigbee.js"; import { @@ -1248,12 +1249,14 @@ describe("Encoding-Decoding", () => { extendedPANId: 15987178197214944733n, txOffset: 16777215, updateId: 0, + globalTlvs: {}, + localTlvs: new Map(), }; expect(macHeader).toStrictEqual(expectedMACHeader); expect(macPayload.byteLength).toStrictEqual(15); expect(beacon).toStrictEqual(expectedBeacon); - expect(macPayload).toStrictEqual(encodeMACZigbeeBeacon(beacon)); + expect(macPayload).toStrictEqual(encodeMACZigbeeBeacon(beacon, 0x1122334455667788n).subarray(0, ZigbeeMACConsts.ZIGBEE_BEACON_LENGTH)); }); it("NET2_ASSOC_REQ_FROM_DEVICE", () => { From cea98ce9ffff7bf67204177b51239d05d0ac5372 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:28:02 +0100 Subject: [PATCH 4/9] fix: remove APS cmds offset return --- src/zigbee-stack/aps-handler.ts | 60 +++++++++------------------ test/zigbee-stack/aps-handler.test.ts | 11 ++--- 2 files changed, 23 insertions(+), 48 deletions(-) diff --git a/src/zigbee-stack/aps-handler.ts b/src/zigbee-stack/aps-handler.ts index 65aa5d4..ae6d6cb 100644 --- a/src/zigbee-stack/aps-handler.ts +++ b/src/zigbee-stack/aps-handler.ts @@ -1306,23 +1306,23 @@ export class APSHandler { switch (cmdId) { case ZigbeeAPSCommandId.UPDATE_DEVICE: { - offset = await this.processUpdateDevice(data, offset, macHeader, nwkHeader, apsHeader); + await this.processUpdateDevice(data, offset, macHeader, nwkHeader, apsHeader); break; } case ZigbeeAPSCommandId.REQUEST_KEY: { - offset = await this.processRequestKey(data, offset, macHeader, nwkHeader, apsHeader); + await this.processRequestKey(data, offset, macHeader, nwkHeader, apsHeader); break; } case ZigbeeAPSCommandId.TUNNEL: { - offset = this.processTunnel(data, offset, macHeader, nwkHeader, apsHeader); + this.processTunnel(data, offset, macHeader, nwkHeader, apsHeader); break; } case ZigbeeAPSCommandId.VERIFY_KEY: { - offset = await this.processVerifyKey(data, offset, macHeader, nwkHeader, apsHeader); + await this.processVerifyKey(data, offset, macHeader, nwkHeader, apsHeader); break; } case ZigbeeAPSCommandId.RELAY_MESSAGE_UPSTREAM: { - offset = await this.processRelayMessageUpstream(data, offset, macHeader, nwkHeader, apsHeader); + await this.processRelayMessageUpstream(data, offset, macHeader, nwkHeader, apsHeader); break; } default: { @@ -1333,11 +1333,6 @@ export class APSHandler { return; } } - - // excess data in packet - // if (offset < data.byteLength) { - // logger.debug(() => `<=== APS CMD contained more data: ${data.toString('hex')}`, NS); - // } } // NOTE: processTransportKey DEVICE SCOPE: not Trust Center (N/A) @@ -1382,7 +1377,7 @@ export class APSHandler { // Bit #0: Frame Counter Synchronization Support // When set to ‘1' the peer device supports APS frame counter synchronization; else, when set to '0’, the peer device does not support APS frame counter synchronization. // Bits #1..#7 are reserved and SHALL be set to '0' by implementations of the current Revision of this specification and ignored when processing. - offset = finalPayload.writeUInt8(0b00000001, offset); + finalPayload.writeUInt8(0b00000001, offset); // encryption NWK=true, APS=true return await this.sendCommand( @@ -1446,7 +1441,7 @@ export class APSHandler { offset += key.copy(finalPayload, offset); offset = finalPayload.writeUInt8(seqNum, offset); offset = finalPayload.writeBigUInt64LE(isBroadcast ? 0n : destination64, offset); - offset = finalPayload.writeBigUInt64LE(this.#context.netParams.eui64, offset); // 0xFFFFFFFFFFFFFFFF in distributed network (no TC) + finalPayload.writeBigUInt64LE(this.#context.netParams.eui64, offset); // 0xFFFFFFFFFFFFFFFF in distributed network (no TC) // see 05-3474-23 #4.4.1.5 // Conversely, a device receiving an APS transport key command MAY choose whether or not APS encryption is required. @@ -1520,7 +1515,7 @@ export class APSHandler { // Bit #0: Frame Counter Synchronization Support // When set to ‘1' the peer device supports APS frame counter synchronization; else, when set to '0’, the peer device does not support APS frame counter synchronization. // Bits #1..#7 are reserved and SHALL be set to '0' by implementations of the current Revision of this specification and ignored when processing. - offset = finalPayload.writeUInt8(0b00000001, offset); + finalPayload.writeUInt8(0b00000001, offset); return await this.sendCommand( ZigbeeAPSCommandId.TRANSPORT_KEY, @@ -1578,7 +1573,7 @@ export class APSHandler { macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader, _apsHeader: ZigbeeAPSHeader, - ): Promise { + ): Promise { const device64 = data.readBigUInt64LE(offset); offset += 8; // Zigbee 2006 and later @@ -1591,8 +1586,7 @@ export class APSHandler { // Joiner Encapsulation Global TLV // - Fragmentation Parameters Global TLV // - If the device is not rejoining: Supported Key Negotiation Methods Global TLV - const [globalTlvs, , tlvsOutOffset] = readZigbeeTlvs(data, offset); - offset = tlvsOutOffset; + const [globalTlvs] = readZigbeeTlvs(data, offset); const joinerTlv = globalTlvs[GlobalTlv.JOINER_ENCAPSULATION]; if (joinerTlv !== undefined) { @@ -1707,8 +1701,6 @@ export class APSHandler { // Security related actions SHALL not be taken on receipt of this. No further processing SHALL be done. await this.#context.disassociate(device16, device64); } - - return offset; } // NOTE: sendUpdateDevice DEVICE SCOPE: not Trust Center (N/A) @@ -1736,7 +1728,7 @@ export class APSHandler { const finalPayload = Buffer.allocUnsafe(9); let offset = 0; offset = finalPayload.writeUInt8(ZigbeeAPSCommandId.REMOVE_DEVICE, offset); - offset = finalPayload.writeBigUInt64LE(target64, offset); + finalPayload.writeBigUInt64LE(target64, offset); return await this.sendCommand( ZigbeeAPSCommandId.REMOVE_DEVICE, @@ -1769,14 +1761,14 @@ export class APSHandler { macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader, apsHeader: ZigbeeAPSHeader, - ): Promise { + ): Promise { // ZigbeeAPSConsts.CMD_KEY_APP_MASTER || ZigbeeAPSConsts.CMD_KEY_TC_LINK const keyType = data.readUInt8(offset); offset += 1; // If the APS Command Request Key message is not APS encrypted, the device SHALL drop the message and no further processing SHALL be done. if (!apsHeader.frameControl.security) { - return offset; + return; } const requester64 = nwkHeader.source64 ?? this.#context.address16ToAddress64.get(nwkHeader.source16!); @@ -1802,7 +1794,6 @@ export class APSHandler { ); } else if (keyType === ZigbeeAPSConsts.CMD_KEY_APP_MASTER) { const partner = data.readBigUInt64LE(offset); - offset += 8; logger.debug( () => @@ -1843,8 +1834,6 @@ export class APSHandler { NS, ); } - - return offset; } // NOTE: sendRequestKey DEVICE SCOPE: not Trust Center (N/A) @@ -1896,19 +1885,16 @@ export class APSHandler { * IMPACT: Not applicable for Coordinator/Trust Center * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) */ - public processTunnel(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader, _apsHeader: ZigbeeAPSHeader): number { + public processTunnel(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader, _apsHeader: ZigbeeAPSHeader): void { const destination = data.readBigUInt64LE(offset); offset += 8; const tunneledAPSFrame = data.subarray(offset); - offset += tunneledAPSFrame.byteLength; logger.debug( () => `<=== APS TUNNEL[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} dst=${destination} tAPSFrame=${tunneledAPSFrame}]`, NS, ); - - return offset; } /** @@ -1934,7 +1920,7 @@ export class APSHandler { let offset = 0; offset = finalPayload.writeUInt8(ZigbeeAPSCommandId.TUNNEL, offset); offset = finalPayload.writeBigUInt64LE(destination64, offset); - offset += tApsCmdFrame.copy(finalPayload, offset); + tApsCmdFrame.copy(finalPayload, offset); return await this.sendCommand( ZigbeeAPSCommandId.TUNNEL, @@ -1978,13 +1964,12 @@ export class APSHandler { macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader, _apsHeader: ZigbeeAPSHeader, - ): Promise { + ): Promise { const keyType = data.readUInt8(offset); offset += 1; const source = data.readBigUInt64LE(offset); offset += 8; const keyHash = data.subarray(offset, offset + ZigbeeAPSConsts.CMD_KEY_LENGTH); - offset += ZigbeeAPSConsts.CMD_KEY_LENGTH; if (macHeader.source16 !== ZigbeeMACConsts.BCAST_ADDR) { logger.debug( @@ -2006,8 +1991,6 @@ export class APSHandler { // TODO: APP link key should also sync counters } } - - return offset; } // NOTE: sendVerifyKey DEVICE SCOPE: routers (N/A), end devices (N/A) @@ -2052,7 +2035,7 @@ export class APSHandler { offset = finalPayload.writeUInt8(ZigbeeAPSCommandId.CONFIRM_KEY, offset); offset = finalPayload.writeUInt8(status, offset); offset = finalPayload.writeUInt8(keyType, offset); - offset = finalPayload.writeBigUInt64LE(destination64, offset); + finalPayload.writeBigUInt64LE(destination64, offset); const result = await this.sendCommand( ZigbeeAPSCommandId.CONFIRM_KEY, @@ -2116,7 +2099,7 @@ export class APSHandler { offset = finalPayload.writeUInt8(0x00, offset); offset = finalPayload.writeUInt8(8 + tApsFrameLength - 1, offset); // per spec, actual data length is `length field + 1` offset = finalPayload.writeBigUInt64LE(destination64, offset); - offset += tApsFrame.copy(finalPayload, offset, 0); + tApsFrame.copy(finalPayload, offset, 0); const result = await this.sendCommand( ZigbeeAPSCommandId.RELAY_MESSAGE_DOWNSTREAM, @@ -2147,9 +2130,8 @@ export class APSHandler { macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader, _apsHeader: ZigbeeAPSHeader, - ): Promise { - const [, localTlvs, tlvsOutOffset] = readZigbeeTlvs(data, offset); - offset = tlvsOutOffset; + ): Promise { + const [, localTlvs] = readZigbeeTlvs(data, offset); const relayMessageTlv = localTlvs.get(0x00); if (relayMessageTlv !== undefined) { @@ -2201,8 +2183,6 @@ export class APSHandler { await this.sendRelayMessageDownstream(nwkHeader.source16, nwkHeader.source64, destination64, apsFrame); } } - - return offset; } // NOTE: sendRelayMessageUpstream DEVICE SCOPE: [unauthorized] routers (N/A), end devices (N/A) diff --git a/test/zigbee-stack/aps-handler.test.ts b/test/zigbee-stack/aps-handler.test.ts index 51ee133..84e26f0 100644 --- a/test/zigbee-stack/aps-handler.test.ts +++ b/test/zigbee-stack/aps-handler.test.ts @@ -1108,9 +1108,8 @@ describe("APS Handler", () => { const sendSpy = vi.spyOn(apsHandler, "sendTransportKeyAPP"); - const offset = await apsHandler.processRequestKey(data, 0, macHeader, nwkHeader, apsHeader); + await apsHandler.processRequestKey(data, 0, macHeader, nwkHeader, apsHeader); - expect(offset).toBe(1); // Should just consume the key type byte expect(sendSpy).not.toHaveBeenCalled(); sendSpy.mockRestore(); }); @@ -1265,9 +1264,7 @@ describe("APS Handler", () => { const nwkHeader = { frameControl: {}, source16: device16 } as ZigbeeNWKHeader; const apsHeader = { frameControl: {} } as ZigbeeAPSHeader; - const result = await apsHandler.processUpdateDevice(data, 0, macHeader, nwkHeader, apsHeader); - - expect(result).toBe(11); + await apsHandler.processUpdateDevice(data, 0, macHeader, nwkHeader, apsHeader); }); it("should process update device command with TLV", async () => { @@ -1296,9 +1293,7 @@ describe("APS Handler", () => { const nwkHeader = { frameControl: {}, source16: device16 } as ZigbeeNWKHeader; const apsHeader = { frameControl: {} } as ZigbeeAPSHeader; - const result = await apsHandler.processUpdateDevice(data, 0, macHeader, nwkHeader, apsHeader); - - expect(result).toBe(32); + await apsHandler.processUpdateDevice(data, 0, macHeader, nwkHeader, apsHeader); }); }); From cd16a70b76dbfc5d9a15bf0c4c6c191687974a0c Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:40:48 +0100 Subject: [PATCH 5/9] feat: NWK handler TLVs --- src/drivers/ot-rcp-driver.ts | 2 +- src/drivers/wip.ts | 8 +- src/zigbee-stack/aps-handler.ts | 63 +-- src/zigbee-stack/frame.ts | 2 +- src/zigbee-stack/mac-handler.ts | 120 ++---- src/zigbee-stack/nwk-handler.ts | 487 ++++++++++++++---------- src/zigbee-stack/stack-context.ts | 69 ++-- src/zigbee/mac.ts | 40 +- src/zigbee/tlvs.ts | 50 +-- src/zigbee/zigbee-aps.ts | 12 +- src/zigbee/zigbee-nwk.ts | 33 +- src/zigbee/zigbee.ts | 20 +- test/compliance/aps.test.ts | 2 +- test/compliance/bdb.test.ts | 68 +--- test/compliance/integration.test.ts | 16 +- test/compliance/mac.test.ts | 39 +- test/compliance/nwk-gp.test.ts | 2 +- test/compliance/nwk.test.ts | 84 ++-- test/compliance/security.test.ts | 24 +- test/compliance/utils.ts | 15 +- test/drivers/ot-rcp-driver.test.ts | 106 +----- test/utils.ts | 16 + test/zigbee-stack/aps-handler.test.ts | 160 ++------ test/zigbee-stack/mac-handler.test.ts | 41 +- test/zigbee-stack/nwk-handler.test.ts | 198 ++++++---- test/zigbee-stack/stack-context.test.ts | 27 +- test/zigbee-stack/zigbee-stack.bench.ts | 39 +- test/zigbee/tlvs.test.ts | 2 +- 28 files changed, 800 insertions(+), 945 deletions(-) diff --git a/src/drivers/ot-rcp-driver.ts b/src/drivers/ot-rcp-driver.ts index 58eb226..8adec09 100644 --- a/src/drivers/ot-rcp-driver.ts +++ b/src/drivers/ot-rcp-driver.ts @@ -531,7 +531,7 @@ export class OTRCPDriver { // #region Network Management - //---- 05-3474-23 #2.5.4.6 + //---- 06-3474-23 #2.5.4.6 // Network Discovery, Get, and Set attributes (both requests and confirms) are mandatory // Zigbee Coordinator: // - The NWK Formation request and confirm, the NWK Leave request, NWK Leave indication, NWK Leave confirm, NWK Join indication, diff --git a/src/drivers/wip.ts b/src/drivers/wip.ts index f906b87..fa7903c 100644 --- a/src/drivers/wip.ts +++ b/src/drivers/wip.ts @@ -1,7 +1,7 @@ import type { TrustCenterPolicies } from "../zigbee-stack/stack-context"; /** - * see 05-3474-23 #3.6.1.7 + * see 06-3474-23 #3.6.1.7 * * SHALL contain information on every device on the current Zigbee network within transmission range, up to some implementation-dependent limit. * The neighbor does not store information about potential networks and candidate parents to join or rejoin. @@ -153,14 +153,14 @@ export type NeighborTableEntry = { }; /** - * see 05-3474-23 Table 4-2 + * see 06-3474-23 Table 4-2 * TODO * This set contains the network keying material, which SHOULD be accessible to commissioning applications. */ export type NWKSecurityMaterialSet = undefined; /** - * see 05-3474-23 Table 2-24 + * see 06-3474-23 Table 2-24 * TODO * The binding table for this device. Binding provides a separation of concerns in the sense that applications MAY operate without having to manage recipient address information for the frames they emit. This information can be input at commissioning time without the main application on the device even being aware of it. */ @@ -169,7 +169,7 @@ export type APSBindingTable = { }; /** - * see 05-3474-23 Table 4-35 + * see 06-3474-23 Table 4-35 * A set of key-pair descriptors containing link keys shared with other devices. */ export type APSDeviceKeyPairSet = { diff --git a/src/zigbee-stack/aps-handler.ts b/src/zigbee-stack/aps-handler.ts index ae6d6cb..5e92220 100644 --- a/src/zigbee-stack/aps-handler.ts +++ b/src/zigbee-stack/aps-handler.ts @@ -174,7 +174,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4.11.1 (Application Link Key establishment) + * 06-3474-23 #4.4.11.1 (Application Link Key establishment) * * Get or generate application link key for a device pair * @@ -200,7 +200,7 @@ export class APSHandler { } /** - * 05-3474-23 #2.2.6.5 (APS duplicate rejection) + * 06-3474-23 #2.2.6.5 (APS duplicate rejection) * * Check whether an incoming APS frame is a duplicate and update the duplicate table accordingly. * @@ -281,7 +281,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4.1 (APS data service) + * 06-3474-23 #4.4.1 (APS data service) * * Send a Zigbee APS DATA frame and track pending ACK if necessary. * @@ -345,7 +345,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4.1 (APS data service) + * 06-3474-23 #4.4.1 (APS data service) * * Send a Zigbee APS DATA frame. * Throws if could not send. @@ -524,7 +524,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4.5 (APS fragmentation) + * 06-3474-23 #4.4.5 (APS fragmentation) * * SPEC COMPLIANCE NOTES: * - ✅ Splits payload into first/remaining chunks respecting fragment overhead constants @@ -613,7 +613,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4.5 (APS fragmentation) + * 06-3474-23 #4.4.5 (APS fragmentation) * * SPEC COMPLIANCE NOTES: * - ✅ Advances to next fragment only after prior block acknowledged @@ -638,7 +638,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4.5 (APS fragmentation reassembly) + * 06-3474-23 #4.4.5 (APS fragmentation reassembly) * * SPEC COMPLIANCE NOTES: * - ✅ Initializes fragment state on FIRST block and records meta fields @@ -749,7 +749,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4.2.3 (APS acknowledgement management) + * 06-3474-23 #4.4.2.3 (APS acknowledgement management) * * SPEC COMPLIANCE NOTES: * - ✅ Starts ack-wait timer using spec default (~1.5 s) and resets on retransmit @@ -778,7 +778,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4.2.3 (APS acknowledgement management) + * 06-3474-23 #4.4.2.3 (APS acknowledgement management) * * SPEC COMPLIANCE NOTES: * - ✅ Retries DATA up to CONFIG_APS_MAX_FRAME_RETRIES per spec guidance (default 3) @@ -821,7 +821,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4.2.3 (APS acknowledgement management) + * 06-3474-23 #4.4.2.3 (APS acknowledgement management) * * SPEC COMPLIANCE NOTES: * - ✅ Matches ACKs using source short address and APS counter per spec tuple @@ -870,7 +870,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4.2.3 (APS acknowledgement) + * 06-3474-23 #4.4.2.3 (APS acknowledgement) * * SPEC COMPLIANCE NOTES: * - ✅ Mirrors counter and cluster metadata per spec Table 4-10 @@ -1008,7 +1008,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4 (APS layer processing) + * 06-3474-23 #4.4 (APS layer processing) * * SPEC COMPLIANCE NOTES: * - ✅ Handles DATA, INTERPAN, CMD frame types per spec definitions @@ -1131,7 +1131,7 @@ export class APSHandler { // #region Commands /** - * 05-3474-23 #4.4.11 (APS command frames) + * 06-3474-23 #4.4.11 (APS command frames) * * SPEC COMPLIANCE NOTES: * - ✅ Encodes APS command header with appropriate delivery mode and security bit per parameters @@ -1290,7 +1290,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4.11 (APS command processing) + * 06-3474-23 #4.4.11 (APS command processing) * * SPEC COMPLIANCE NOTES: * - ✅ Dispatches APS command IDs to the appropriate handler per Table 4-28 @@ -1338,7 +1338,7 @@ export class APSHandler { // NOTE: processTransportKey DEVICE SCOPE: not Trust Center (N/A) /** - * 05-3474-23 #4.4.11.1 + * 06-3474-23 #4.4.11.1 * * SPEC COMPLIANCE NOTES: * - ✅ Correctly uses CMD_KEY_TC_LINK type (0x01) per spec Table 4-17 @@ -1404,7 +1404,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4.11.1 #4.4.11.1.3.2 + * 06-3474-23 #4.4.11.1 #4.4.11.1.3.2 * * SPEC COMPLIANCE NOTES: * - ✅ Correctly uses CMD_KEY_STANDARD_NWK type (0x00) per spec Table 4-17 @@ -1443,7 +1443,7 @@ export class APSHandler { offset = finalPayload.writeBigUInt64LE(isBroadcast ? 0n : destination64, offset); finalPayload.writeBigUInt64LE(this.#context.netParams.eui64, offset); // 0xFFFFFFFFFFFFFFFF in distributed network (no TC) - // see 05-3474-23 #4.4.1.5 + // see 06-3474-23 #4.4.1.5 // Conversely, a device receiving an APS transport key command MAY choose whether or not APS encryption is required. // This is most often done during initial joining. // For example, during joining a device that has no preconfigured link key would only accept unencrypted transport key messages, @@ -1487,7 +1487,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4.11.1 #4.4.11.1.3.3 + * 06-3474-23 #4.4.11.1 #4.4.11.1.3.3 * * SPEC COMPLIANCE NOTES: * - ✅ Sets CMD_KEY_APP_LINK (0x03) and includes partner64 + initiator flag per Table 4-17 @@ -1541,7 +1541,7 @@ export class APSHandler { } /** - * 05-3474-23 #4.4.11.2 + * 06-3474-23 #4.4.11.2 * * SPEC COMPLIANCE NOTES: * - ✅ Correctly decodes all mandatory fields: device64, device16, status @@ -1579,6 +1579,7 @@ export class APSHandler { // Zigbee 2006 and later const device16 = data.readUInt16LE(offset); offset += 2; + // ZigbeeAPSUpdateDeviceStatus const status = data.readUInt8(offset); offset += 1; // joiner TLVs: one or more TLVs received during Network Commissioning by the parent router, not present if { - let offset = 0; - switch (macHeader.commandId!) { case MACCommandId.ASSOC_REQ: { - offset = await this.processAssocReq(data, offset, macHeader); + await this.processAssocReq(data, macHeader); break; } case MACCommandId.BEACON_REQ: { - offset = await this.processBeaconReq(data, offset, macHeader); + await this.processBeaconReq(data, macHeader); break; } case MACCommandId.DATA_RQ: { - offset = await this.processDataReq(data, offset, macHeader); + await this.processDataReq(data, macHeader); break; } case MACCommandId.DISASSOC_NOTIFY: { - offset = await this.processDisassocNotify(data, offset, macHeader); + await this.processDisassocNotify(data, macHeader); break; } // TODO: other cases? @@ -288,11 +286,6 @@ export class MACHandler { return; } } - - // excess data in packet - // if (offset < data.byteLength) { - // logger.debug(() => `<=== MAC CMD contained more data: ${data.toString('hex')}`, NS); - // } } /** @@ -313,52 +306,45 @@ export class MACHandler { * - ✅ Delivers TRANSPORT_KEY_NWK after successful association (Zigbee Trust Center requirement) * - ✅ Uses MAC capabilities to determine device type correctly * DEVICE SCOPE: Coordinator, routers (N/A) - * - * @param data Command data - * @param offset Current offset in data - * @param macHeader MAC header - * @returns New offset after processing */ - public async processAssocReq(data: Buffer, offset: number, macHeader: MACHeader): Promise { - const capabilities = data.readUInt8(offset); - offset += 1; + public async processAssocReq(data: Buffer, macHeader: MACHeader): Promise { + if (macHeader.source64 === undefined) { + logger.debug(() => `<=x= MAC ASSOC_REQ[macSrc=${macHeader.source16}:${macHeader.source64}] Invalid source64`, NS); + return; + } + + const capabilities = data.readUInt8(0); logger.debug(() => `<=== MAC ASSOC_REQ[macSrc=${macHeader.source16}:${macHeader.source64} cap=${capabilities}]`, NS); - if (macHeader.source64 === undefined) { - logger.debug(() => `<=x= MAC ASSOC_REQ[macSrc=${macHeader.source16}:${macHeader.source64} cap=${capabilities}] Invalid source64`, NS); - } else { - const device = this.#context.deviceTable.get(macHeader.source64); - const address16 = device?.address16; - const decodedCap = decodeMACCapabilities(capabilities); - const [status, newAddress16, requiresTransportKey] = await this.#context.associate( - address16, - macHeader.source64, - !device?.authorized /* rejoin only if was previously authorized */, - decodedCap, - true /* neighbor */, - address16 === undefined && !this.#context.associationPermit, - ); - - this.#context.pendingAssociations.set(macHeader.source64, { - sendResp: async () => { - await this.sendAssocRsp(macHeader.source64!, newAddress16, status); - - if (status === MACAssociationStatus.SUCCESS && requiresTransportKey) { - await this.#callbacks.onAPSSendTransportKeyNWK( - newAddress16, - this.#context.netParams.networkKey, - this.#context.netParams.networkKeySequenceNumber, - macHeader.source64!, - ); - this.#context.markNetworkKeyTransported(macHeader.source64!); - } - }, - timestamp: Date.now(), - }); - } + const device = this.#context.deviceTable.get(macHeader.source64); + const address16 = device?.address16; + const decodedCap = decodeMACCapabilities(capabilities); + const [status, newAddress16, requiresTransportKey] = await this.#context.associate( + address16, + macHeader.source64, + !device?.authorized /* rejoin only if was previously authorized */, + decodedCap, + true /* neighbor */, + address16 === undefined && !this.#context.associationPermit, + ); - return offset; + this.#context.pendingAssociations.set(macHeader.source64, { + sendResp: async () => { + await this.sendAssocRsp(macHeader.source64!, newAddress16, status); + + if (status === MACAssociationStatus.SUCCESS && requiresTransportKey) { + await this.#callbacks.onAPSSendTransportKeyNWK( + newAddress16, + this.#context.netParams.networkKey, + this.#context.netParams.networkKeySequenceNumber, + macHeader.source64!, + ); + this.#context.markNetworkKeyTransported(macHeader.source64!); + } + }, + timestamp: Date.now(), + }); } // NOTE: processAssocRsp DEVICE SCOPE: routers (N/A), end devices (N/A) @@ -372,9 +358,8 @@ export class MACHandler { * - ⚠️ Does not emit confirmation back to child (not required for coordinator role) * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) */ - public async processDisassocNotify(data: Buffer, offset: number, macHeader: MACHeader): Promise { - const reason = data.readUInt8(offset); - offset += 1; + public async processDisassocNotify(data: Buffer, macHeader: MACHeader): Promise { + const reason = data.readUInt8(0); logger.debug(() => `<=== MAC DISASSOC_NOTIFY[macSrc=${macHeader.source16}:${macHeader.source64} reason=${reason}]`, NS); @@ -384,8 +369,6 @@ export class MACHandler { await this.#context.disassociate(source16, macHeader.source64); } - - return offset; } /** @@ -397,11 +380,6 @@ export class MACHandler { * - ✅ Sends unicast command secured according to caller (none for initial association) * - ⚠️ Relies on pendingAssociations bookkeeping to ensure indirect delivery, matching spec requirement * DEVICE SCOPE: Coordinator, routers (N/A) - * - * @param dest64 Destination IEEE address - * @param newAddress16 Assigned network address - * @param status Association status - * @returns True if success sending */ public async sendAssocRsp(dest64: bigint, newAddress16: number, status: MACAssociationStatus | number): Promise { logger.debug(() => `===> MAC ASSOC_RSP[dst64=${dest64} newAddr16=${newAddress16} status=${status}]`, NS); @@ -455,13 +433,8 @@ export class MACHandler { * * This is acceptable - indicates no time sync ✅ * - updateId from context ✅ * DEVICE SCOPE: Coordinator, routers (N/A) - * - * @param _data Command data (unused) - * @param offset Current offset in data - * @param _macHeader MAC header (unused) - * @returns New offset after processing */ - public async processBeaconReq(_data: Buffer, offset: number, _macHeader: MACHeader): Promise { + public async processBeaconReq(_data: Buffer, _macHeader: MACHeader): Promise { logger.debug(() => "<=== MAC BEACON_REQ[]", NS); const macSeqNum = this.nextSeqNum(); @@ -513,8 +486,6 @@ export class MACHandler { logger.debug(() => `===> MAC BEACON[seqNum=${macSeqNum}]`, NS); await this.sendFrame(macSeqNum, macFrame, undefined, undefined); - - return offset; } /** @@ -542,13 +513,8 @@ export class MACHandler { * If yes, sends frame. If no, sends ACK with framePending=false" * This is handled correctly by the indirect transmission mechanism. * DEVICE SCOPE: Coordinator, routers (N/A) - * - * @param _data Command data (unused) - * @param offset Current offset in data - * @param macHeader MAC header - * @returns New offset after processing */ - public async processDataReq(_data: Buffer, offset: number, macHeader: MACHeader): Promise { + public async processDataReq(_data: Buffer, macHeader: MACHeader): Promise { logger.debug(() => `<=== MAC DATA_RQ[macSrc=${macHeader.source16}:${macHeader.source64}]`, NS); let address64 = macHeader.source64; @@ -585,8 +551,6 @@ export class MACHandler { } } } - - return offset; } // #endregion diff --git a/src/zigbee-stack/nwk-handler.ts b/src/zigbee-stack/nwk-handler.ts index db8fe1c..a97e4c8 100644 --- a/src/zigbee-stack/nwk-handler.ts +++ b/src/zigbee-stack/nwk-handler.ts @@ -9,10 +9,12 @@ import { type MACHeader, ZigbeeMACConsts, } from "../zigbee/mac.js"; +import { GlobalTlv, GlobalTlvConsts, readZigbeeTlvs } from "../zigbee/tlvs.js"; import { ZigbeeConsts, ZigbeeKeyType, type ZigbeeSecurityHeader, ZigbeeSecurityLevel } from "../zigbee/zigbee.js"; import { encodeZigbeeNWKFrame, ZigbeeNWKCommandId, + ZigbeeNWKCommissioningType, ZigbeeNWKConsts, ZigbeeNWKFrameType, type ZigbeeNWKHeader, @@ -43,8 +45,8 @@ export const CONFIG_NWK_MAX_HOPS = CONFIG_NWK_MAX_DEPTH * 2; // 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 total delivery time for a broadcast transmission to be delivered to all RxOnWhenIdle=TRUE devices in the network. (msec) */ +// const CONFIG_NWK_BCAST_DELIVERY_TIME = 9000; /** 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) */ @@ -157,7 +159,7 @@ export class NWKHandler { // #region Route Management /** - * 05-3474-23 #3.4.8 (Link Status command) + * 06-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 @@ -221,7 +223,7 @@ export class NWKHandler { } /** - * 05-3474-23 #3.6.3.5.2 (Many-to-One Route Discovery) + * 06-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 @@ -246,7 +248,7 @@ export class NWKHandler { * 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): + * SPEC COMPLIANCE NOTES (06-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) @@ -435,7 +437,7 @@ export class NWKHandler { } /** - * 05-3474-23 #3.6.3.3 + * 06-3474-23 #3.6.3.3 * * Mark a route as successfully used * @@ -458,7 +460,7 @@ export class NWKHandler { } /** - * 05-3474-23 #3.6.3.3 + * 06-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. @@ -517,7 +519,7 @@ export class NWKHandler { } /** - * 05-3474-23 #3.6.3.3 (Source routing tables) + * 06-3474-23 #3.6.3.3 (Source routing tables) * * Create a new source route table entry * @@ -540,7 +542,7 @@ export class NWKHandler { } /** - * 05-3474-23 #3.6.3.3 + * 06-3474-23 #3.6.3.3 * * Check if a source route already exists in the table * @@ -584,7 +586,7 @@ export class NWKHandler { // #region Commands /** - * 05-3474-23 #3.4 (NWK command frames) + * 06-3474-23 #3.4 (NWK command frames) * * SPEC COMPLIANCE NOTES: * - ✅ Prepends Zigbee NWK header and optional security per caller (spec Table 3-5) @@ -710,7 +712,7 @@ export class NWKHandler { } /** - * 05-3474-23 #3.4 (NWK command processing) + * 06-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.) @@ -726,47 +728,47 @@ export class NWKHandler { switch (cmdId) { case ZigbeeNWKCommandId.ROUTE_REQ: { - offset = await this.processRouteReq(data, offset, macHeader, nwkHeader); + await this.processRouteReq(data, offset, macHeader, nwkHeader); break; } case ZigbeeNWKCommandId.ROUTE_REPLY: { - offset = this.processRouteReply(data, offset, macHeader, nwkHeader); + this.processRouteReply(data, offset, macHeader, nwkHeader); break; } case ZigbeeNWKCommandId.NWK_STATUS: { - offset = await this.processStatus(data, offset, macHeader, nwkHeader); + await this.processStatus(data, offset, macHeader, nwkHeader); break; } case ZigbeeNWKCommandId.LEAVE: { - offset = await this.processLeave(data, offset, macHeader, nwkHeader); + await this.processLeave(data, offset, macHeader, nwkHeader); break; } case ZigbeeNWKCommandId.ROUTE_RECORD: { - offset = this.processRouteRecord(data, offset, macHeader, nwkHeader); + this.processRouteRecord(data, offset, macHeader, nwkHeader); break; } case ZigbeeNWKCommandId.REJOIN_REQ: { - offset = await this.processRejoinReq(data, offset, macHeader, nwkHeader); + await this.processRejoinReq(data, offset, macHeader, nwkHeader); break; } case ZigbeeNWKCommandId.LINK_STATUS: { - offset = this.processLinkStatus(data, offset, macHeader, nwkHeader); + this.processLinkStatus(data, offset, macHeader, nwkHeader); break; } case ZigbeeNWKCommandId.NWK_REPORT: { - offset = this.processReport(data, offset, macHeader, nwkHeader); + this.processReport(data, offset, macHeader, nwkHeader); break; } case ZigbeeNWKCommandId.ED_TIMEOUT_REQUEST: { - offset = await this.processEdTimeoutRequest(data, offset, macHeader, nwkHeader); + await this.processEdTimeoutRequest(data, offset, macHeader, nwkHeader); break; } case ZigbeeNWKCommandId.LINK_PWR_DELTA: { - offset = this.processLinkPwrDelta(data, offset, macHeader, nwkHeader); + await this.processLinkPwrDelta(data, offset, macHeader, nwkHeader); break; } case ZigbeeNWKCommandId.COMMISSIONING_REQUEST: { - offset = await this.processCommissioningRequest(data, offset, macHeader, nwkHeader); + await this.processCommissioningRequest(data, offset, macHeader, nwkHeader); break; } default: { @@ -777,22 +779,15 @@ export class NWKHandler { 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) + * 06-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 @@ -801,7 +796,7 @@ export class NWKHandler { * @param nwkHeader NWK header * @returns New offset after processing */ - public async processRouteReq(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): Promise { + public async processRouteReq(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): Promise { const options = data.readUInt8(offset); offset += 1; const manyToOne = (options & ZigbeeNWKConsts.CMD_ROUTE_OPTION_MANY_MASK) >> 3; // ZigbeeNWKManyToOne @@ -815,9 +810,10 @@ export class NWKHandler { if (options & ZigbeeNWKConsts.CMD_ROUTE_OPTION_DEST_EXT) { destination64 = data.readBigUInt64LE(offset); - offset += 8; } + // NOTE: skipping TLVs, no current use + logger.debug( () => `<=== NWK ROUTE_REQ[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} id=${id} dst=${destination16}:${destination64} pCost=${pathCost} mto=${manyToOne}]`, @@ -835,18 +831,15 @@ export class NWKHandler { destination64, ); } - - return offset; } /** - * 05-3474-23 #3.4.1 (Route Request) + * 06-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 @@ -872,6 +865,8 @@ export class NWKHandler { offset = finalPayload.writeBigUInt64LE(destination64!, offset); } + // NOTE: skipping TLVs, no current use + return await this.sendCommand( ZigbeeNWKCommandId.ROUTE_REQ, finalPayload, @@ -884,16 +879,15 @@ export class NWKHandler { } /** - * 05-3474-23 #3.4.2 (Route Reply) + * 06-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) */ - public processRouteReply(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): number { + public processRouteReply(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): void { const options = data.readUInt8(offset); offset += 1; const id = data.readUInt8(offset); @@ -914,11 +908,9 @@ export class NWKHandler { if (options & ZigbeeNWKConsts.CMD_ROUTE_OPTION_RESP_EXT) { responder64 = data.readBigUInt64LE(offset); - offset += 8; } - // TODO - // const [tlvs, tlvsOutOffset] = decodeZigbeeNWKTLVs(data, offset); + // NOTE: skipping TLVs, no current use logger.debug( () => @@ -963,18 +955,15 @@ export class NWKHandler { this.markRouteSuccess(responder16); } - - return offset; } /** - * 05-3474-23 #3.4.2 / #3.6.4.5.2 (Route Reply) + * 06-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 @@ -1024,8 +1013,7 @@ export class NWKHandler { offset = finalPayload.writeBigUInt64LE(responder64!, offset); } - // TODO - // const [tlvs, tlvsOutOffset] = encodeZigbeeNWKTLVs(); + // NOTE: skipping TLVs, no current use return await this.sendCommand( ZigbeeNWKCommandId.ROUTE_REPLY, @@ -1039,20 +1027,19 @@ export class NWKHandler { } /** - * 05-3474-23 #3.4.3 + * 06-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 */ - public async processStatus(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): Promise { + public async processStatus(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): Promise { 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 @@ -1067,14 +1054,12 @@ export class NWKHandler { ) { // 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 === ZigbeeNWKStatus.ADDRESS_CONFLICT) { // In case of an address conflict, it SHALL contain the offending network address. target16 = data.readUInt16LE(offset); - offset += 2; if (target16 !== ZigbeeConsts.COORDINATOR_ADDRESS) { const device64 = this.#context.address16ToAddress64.get(target16); @@ -1090,20 +1075,17 @@ export class NWKHandler { } } - // TODO - // const [tlvs, tlvsOutOffset] = decodeZigbeeNWKTLVs(data, offset); + // NOTE: skipping TLVs, no current use logger.debug( () => `<=== NWK NWK_STATUS[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} status=${ZigbeeNWKStatus[status]} dst16=${target16}]`, NS, ); - - return offset; } /** - * 05-3474-23 #3.4.3 + * 06-3474-23 #3.4.3 * * SPEC COMPLIANCE: * - ✅ Sends to appropriate destination (broadcast or unicast) @@ -1129,8 +1111,7 @@ export class NWKHandler { finalPayload = Buffer.from([ZigbeeNWKCommandId.NWK_STATUS, status]); } - // TODO - // const [tlvs, tlvsOutOffset] = encodeZigbeeNWKTLVs(); + // NOTE: skipping TLVs, no current use return await this.sendCommand( ZigbeeNWKCommandId.NWK_STATUS, @@ -1144,7 +1125,7 @@ export class NWKHandler { } /** - * 05-3474-23 #3.4.4 (Leave command) + * 06-3474-23 #3.4.4 (Leave command) * * SPEC COMPLIANCE NOTES: * - ✅ Parses removeChildren/request/rejoin flags from options byte (Table 3-16) @@ -1152,9 +1133,8 @@ export class NWKHandler { * - ⚠️ removeChildren flag purposely ignored (deprecated) * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) */ - public async processLeave(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): Promise { + public async processLeave(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): Promise { const options = data.readUInt8(offset); - offset += 1; const removeChildren = !!(options & ZigbeeNWKConsts.CMD_LEAVE_OPTION_REMOVE_CHILDREN); const request = !!(options & ZigbeeNWKConsts.CMD_LEAVE_OPTION_REQUEST); const rejoin = !!(options & ZigbeeNWKConsts.CMD_LEAVE_OPTION_REJOIN); @@ -1168,12 +1148,10 @@ export class NWKHandler { if (!rejoin && !request) { await this.#context.disassociate(nwkHeader.source16, nwkHeader.source64); } - - return offset; } /** - * 05-3474-23 #3.4.4 (Leave command) + * 06-3474-23 #3.4.4 (Leave command) * * SPEC COMPLIANCE NOTES: * - ✅ Sets request bit (bit6) and optional rejoin bit based on caller input @@ -1206,7 +1184,7 @@ export class NWKHandler { } /** - * 05-3474-23 #3.4.5 + * 06-3474-23 #3.4.5 * * SPEC COMPLIANCE NOTES: * - ✅ Correctly decodes relayCount and relay addresses @@ -1230,7 +1208,7 @@ export class NWKHandler { * @param nwkHeader NWK header * @returns New offset after processing */ - public processRouteRecord(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): number { + public processRouteRecord(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): void { const relayCount = data.readUInt8(offset); offset += 1; const relays: number[] = []; @@ -1265,28 +1243,22 @@ export class NWKHandler { entries.push(entry); } } - - return offset; } // NOTE: sendRouteRecord DEVICE SCOPE: routers (N/A), end devices (N/A) /** - * 05-3474-23 #3.4.6 - * Optional + * 06-3474-23 #3.4.6 + * Optional: Child Rejoining the Network to a Legacy Parent (Pre-R23) * * SPEC COMPLIANCE NOTES: * - ✅ Correctly decodes capabilities byte * - ✅ Determines rejoin type based on frameControl.security: * - security=false: Trust Center Rejoin (unsecured) * - security=true: NWK rejoin (secured with NWK key) - * - ⚠️ TRUST CENTER REJOIN HANDLING: + * - ✅ TRUST CENTER REJOIN HANDLING: * - Checks if device is known and authorized ✅ * - Denies rejoin if device unknown or unauthorized ✅ - * - SPEC WARNING in comment about unsecured packets from neighbors - * "Unsecured Packets at the network layer claiming to be from existing neighbors... - * must not rewrite legitimate data in nwkNeighborTable" - * This is a critical security requirement ✅ * - ✅ Centralized Trust Center enforces coordinator EUI64; distributed/uninitialized modes not supported here (N/A) * - ✅ Calls context associate with correct parameters: * - initialJoin=false (this is a rejoin) ✅ @@ -1308,10 +1280,8 @@ export class NWKHandler { * @param nwkHeader NWK header * @returns New offset after processing */ - public async processRejoinReq(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): Promise { + public async processRejoinReq(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): Promise { const capabilities = data.readUInt8(offset); - offset += 1; - const decodedCap = decodeMACCapabilities(capabilities); logger.debug( @@ -1325,11 +1295,10 @@ export class NWKHandler { if (!nwkHeader.frameControl.security) { // Trust Center Rejoin - if (source64 === undefined) { if (nwkHeader.source16 === undefined) { // invalid, drop completely, should never happen - return offset; + return; } source64 = this.#context.address16ToAddress64.get(nwkHeader.source16); @@ -1358,6 +1327,9 @@ export class NWKHandler { macHeader.source16 === nwkHeader.source16, deny, ); + // TODO: + // The relationship field of the new neighbor table entry SHALL be set to the value 0x01 only if the mechanism was NWK Rejoin and had NWK Layer security. + // Otherwise, the relationship field SHALL be set to 0x05 indicating an unauthenticated child. await this.sendRejoinResp(nwkHeader.source16!, newAddress16, status); @@ -1373,8 +1345,6 @@ export class NWKHandler { } // NOTE: a device does not have to verify its trust center link key with the APSME-VERIFY-KEY services after a rejoin. - - return offset; } // NOTE: sendRejoinReq DEVICE SCOPE: routers (N/A), end devices (N/A) @@ -1382,7 +1352,7 @@ export class NWKHandler { // NOTE: processRejoinResp DEVICE SCOPE: routers (N/A), end devices (N/A) /** - * 05-3474-23 #3.4.7 (Rejoin Response) + * 06-3474-23 #3.4.7 (Rejoin Response) * * SPEC COMPLIANCE NOTES: * - ✅ Returns new short address and status per Table 3-19 @@ -1412,7 +1382,7 @@ export class NWKHandler { } /** - * 05-3474-23 #3.4.8 + * 06-3474-23 #3.4.8 * * SPEC COMPLIANCE NOTES: * - ✅ Correctly decodes options byte, link count, and link entries @@ -1431,9 +1401,6 @@ export class NWKHandler { * - Spec #3.4.8 describes link status for neighbor table maintenance * - Using it to build source routes is an implementation optimization * - This may not be fully spec-compliant but is pragmatic - * - ⚠️ Neighbor table maintenance is purposely not implemented due to "unlimited" table size on host - * - No neighbor table present, only a flag in device table - * - This is a significant spec deviation * - ⚠️ COST CALCULATION: Uses incoming cost directly as path cost * - This may underestimate total path cost for multi-hop routes * - Should consider accumulated path cost through intermediaries @@ -1445,7 +1412,7 @@ export class NWKHandler { * @param nwkHeader NWK header * @returns New offset after processing */ - public processLinkStatus(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): number { + public processLinkStatus(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): void { // Bit: 0 – 4 5 6 7 // Entry count First frame Last frame Reserved const options = data.readUInt8(offset); @@ -1528,12 +1495,10 @@ export class NWKHandler { return `<=== NWK LINK_STATUS[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} first=${firstFrame} last=${lastFrame} links=${linksStr}]`; }, NS); - - return offset; } /** - * 05-3474-23 #3.4.8 (Link Status command) + * 06-3474-23 #3.4.8 (Link Status command) * * SPEC COMPLIANCE NOTES: * - ✅ Fragments link list across multiple frames respecting MAX_PAYLOAD (27 entries per frame) @@ -1548,51 +1513,43 @@ export class NWKHandler { logger.debug(() => { let linksStr = ""; - for (const link of links) { - linksStr += `{${link.address}|in:${link.incomingCost}|out:${link.outgoingCost}}`; + for (const { address, incomingCost, outgoingCost } of links) { + linksStr += `{${address}|in:${incomingCost}|out:${outgoingCost}}`; } return `===> NWK LINK_STATUS[links=${linksStr}]`; }, NS); - // TODO: check repeat logic - const linkSize = links.length * 3; - const maxLinksPayloadSize = ZigbeeNWKConsts.PAYLOAD_MIN_SIZE - 2; // 84 (- cmdId[1] - options[1]) - const maxLinksPerFrame = (maxLinksPayloadSize / 3) | 0; // 27 - const frameCount = Math.ceil((linkSize + 3) / maxLinksPayloadSize); // (+ repeated link[3]) + const linksLen = links.length; + const maxLinksPayloadSize = ZigbeeNWKConsts.PAYLOAD_MIN_SIZE - 2; // cmdId[1] + options[1] + const maxLinksPerFrame = (maxLinksPayloadSize / 3) | 0; let linksOffset = 0; + let isFirstFrame = true; + let isLastFrame = false; - for (let i = 0; i < frameCount; i++) { - const linkCount = links.length - i * maxLinksPerFrame; - const frameSize = 2 + Math.min(linkCount * 3, maxLinksPayloadSize); + do { + const linkCount = Math.min(maxLinksPerFrame, linksLen - linksOffset); + isLastFrame = linksOffset + linkCount >= linksLen; const options = - (((i === 0 ? 1 : 0) << 5) & ZigbeeNWKConsts.CMD_LINK_OPTION_FIRST_FRAME) | - (((i === frameCount - 1 ? 1 : 0) << 6) & ZigbeeNWKConsts.CMD_LINK_OPTION_LAST_FRAME) | + (isFirstFrame ? ZigbeeNWKConsts.CMD_LINK_OPTION_FIRST_FRAME : 0) | + (isLastFrame ? ZigbeeNWKConsts.CMD_LINK_OPTION_LAST_FRAME : 0) | (linkCount & ZigbeeNWKConsts.CMD_LINK_OPTION_COUNT_MASK); - const finalPayload = Buffer.allocUnsafe(frameSize); - let finalPayloadOffset = 0; - finalPayload.writeUInt8(ZigbeeNWKCommandId.LINK_STATUS, finalPayloadOffset); - finalPayloadOffset += 1; - finalPayload.writeUInt8(options, finalPayloadOffset); - finalPayloadOffset += 1; - - for (let j = 0; j < linkCount; j++) { - const link = links[linksOffset]; - finalPayload.writeUInt16LE(link.address, finalPayloadOffset); - finalPayloadOffset += 2; - finalPayload.writeUInt8( - (link.incomingCost & ZigbeeNWKConsts.CMD_LINK_INCOMING_COST_MASK) | - ((link.outgoingCost << 4) & ZigbeeNWKConsts.CMD_LINK_OUTGOING_COST_MASK), - finalPayloadOffset, + const finalPayload = Buffer.allocUnsafe(2 + linkCount * 3); + let fpOffset = 0; + fpOffset = finalPayload.writeUInt8(ZigbeeNWKCommandId.LINK_STATUS, fpOffset); + fpOffset = finalPayload.writeUInt8(options, fpOffset); + + for (let i = 0; i < linkCount; i++) { + const { address, incomingCost, outgoingCost } = links[linksOffset + i]; + fpOffset = finalPayload.writeUInt16LE(address, fpOffset); + fpOffset = finalPayload.writeUInt8( + (incomingCost & ZigbeeNWKConsts.CMD_LINK_INCOMING_COST_MASK) | + ((outgoingCost << 4) & ZigbeeNWKConsts.CMD_LINK_OUTGOING_COST_MASK), + fpOffset, ); - finalPayloadOffset += 1; - - // last in previous frame is repeated first in next frame - if (j < linkCount - 1) { - linksOffset++; - } } + // TODO: jitter / delay? await this.sendCommand( ZigbeeNWKCommandId.LINK_STATUS, finalPayload, @@ -1602,26 +1559,26 @@ export class NWKHandler { undefined, // nwkDest64 1, // nwkRadius ); - } + + // last in previous frame is repeated first in next frame + linksOffset += linkCount - 1; + isFirstFrame = false; + } while (!isLastFrame); } /** - * 05-3474-23 #3.4.9 (deprecated in R23) + * 06-3474-23 #3.4.9 * * SPEC COMPLIANCE: - * - ✅ Correctly decodes options, EPID, updateID, panID * - ✅ Handles PAN ID conflict reports * - ✅ Logs report information - * - ❌ NOT IMPLEMENTED: Channel update action - * - ❌ NOT IMPLEMENTED: Network update propagation * - ❌ NOT IMPLEMENTED: PAN ID conflict resolution - * - ❌ NOT IMPLEMENTED: TLV support (R23) + * * DEVICE SCOPE: Coordinator, routers (N/A) * * NOTE: Deprecated in R23, should no longer be sent by R23 devices - * IMPACT: Coordinator doesn't act on network reports */ - public processReport(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): number { + public processReport(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): void { const options = data.readUInt8(offset); offset += 1; const reportCount = options & ZigbeeNWKConsts.CMD_NWK_REPORT_COUNT_MASK; @@ -1646,35 +1603,57 @@ export class NWKHandler { `<=== NWK NWK_REPORT[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} extPANId=${extendedPANId} repType=${reportType} conflictPANIds=${conflictPANIds}]`, NS, ); - - return offset; } // NOTE: sendReport deprecated in R23 // NOTE: processUpdate DEVICE SCOPE: routers (N/A), end devices (N/A) - // TODO: sendUpdate + /** + * 06-3474-23 #3.4.10 (Network Update Command) + * + * Must be sent before the internal change, so the MAC PAN ID is still the old one to properly reach devices. + * Per spec, after sending this, should start a timer of `CONFIG_NWK_BCAST_DELIVERY_TIME` then apply the changes internally. + * + * SPEC COMPLIANCE NOTES: + * - TODO + * + * DEVICE SCOPE: Coordinator, routers (N/A) + */ + public async sendUpdatePanId(extendedPanId: bigint, nwkUpdateId: number, newPanId: number): Promise { + const finalPayload = Buffer.allocUnsafe(12); + let offset = 0; + offset = finalPayload.writeUInt8(0b00000001, offset); + offset = finalPayload.writeBigUInt64LE(extendedPanId, offset); + offset = finalPayload.writeUInt8(nwkUpdateId, offset); + finalPayload.writeUInt16LE(newPanId); + + return await this.sendCommand( + ZigbeeNWKCommandId.NWK_UPDATE, + finalPayload, + true, // nwkSecurity + ZigbeeConsts.COORDINATOR_ADDRESS, + ZigbeeConsts.BCAST_SLEEPY, // nwkDest16 + undefined, // nwkDest64 + CONFIG_NWK_MAX_HOPS, // nwkRadius + ); + } /** - * 05-3474-23 #3.4.11 + * 06-3474-23 #3.4.11 * * SPEC COMPLIANCE: * - ✅ Decodes requested timeout index and configuration octet per spec Table 3-54 * - ✅ Validates timeout against END_DEVICE_TIMEOUT_TABLE and device presence before accepting * - ✅ Updates StackContext end-device timeout metadata and responds with status codes (SUCCESS/INCORRECT_VALUE/UNSUPPORTED_FEATURE) - * - ⚠️ Still lacks parent policy enforcement (e.g., max timeout per device class) - * - ❌ NOT IMPLEMENTED: Keep-alive scheduling or timeout expiration handling - * - ❌ NOT IMPLEMENTED: TLV processing for R23 extensions * DEVICE SCOPE: Coordinator, routers (N/A) */ - public async processEdTimeoutRequest(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): Promise { + public async processEdTimeoutRequest(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): Promise { // index into END_DEVICE_TIMEOUT_TABLE_MS const requestedTimeout = data.readUInt8(offset); offset += 1; // not currently used (all reserved) const configuration = data.readUInt8(offset); - offset += 1; logger.debug( () => @@ -1685,25 +1664,26 @@ export class NWKHandler { // sanity check if (nwkHeader.source16 !== undefined) { const timeoutResolved = END_DEVICE_TIMEOUT_TABLE_MS[requestedTimeout]; - const source64 = nwkHeader.source64 ?? this.#context.address16ToAddress64.get(nwkHeader.source16); let status = 0x00; if (timeoutResolved === undefined) { status = 0x01; - } else if (source64 === undefined) { - status = 0x02; } else { + const source64 = nwkHeader.source64 ?? this.#context.address16ToAddress64.get(nwkHeader.source16); + + if (source64 === undefined) { + return; + } + const metadata = this.#context.updateEndDeviceTimeout(source64, requestedTimeout); if (metadata === undefined) { - status = 0x02; + return; } } await this.sendEdTimeoutResponse(nwkHeader.source16, requestedTimeout, status); } - - return offset; } // NOTE: sendEdTimeoutRequest DEVICE SCOPE: routers (N/A), end devices (N/A) @@ -1711,13 +1691,12 @@ export class NWKHandler { // NOTE: processEdTimeoutResponse DEVICE SCOPE: routers (N/A), end devices (N/A) /** - * 05-3474-23 #3.4.12 + * 06-3474-23 #3.4.12 * * SPEC COMPLIANCE: * - ✅ Populates status field with SUCCESS/INCORRECT_VALUE/UNSUPPORTED_FEATURE based on request validation * - ✅ Sends parent information bitmap indicating keep-alive support (defaults to DATA_POLL + REQUEST + POWER_NEGOTIATION) * - ✅ Applies NWK security and unicasts to requester as required - * - ❌ NOT IMPLEMENTED: TLV extensions (R23) * DEVICE SCOPE: Coordinator, routers (N/A) */ public async sendEdTimeoutResponse( @@ -1748,25 +1727,35 @@ export class NWKHandler { } /** - * 05-3474-23 #3.4.13 + * 06-3474-23 #3.4.13 * * SPEC COMPLIANCE: * - ✅ Decodes transmit power delta * - ✅ Logs power delta information - * - ✅ Extracts nested TLVs (if present) * - ❌ NOT IMPLEMENTED: Power adjustment action * - ❌ NOT IMPLEMENTED: Feedback mechanism - * - ❌ NOT IMPLEMENTED: R23 TLV processing - * DEVICE SCOPE: Coordinator, routers (N/A) * - * IMPACT: Receives command but doesn't adjust transmit power + * DEVICE SCOPE: Coordinator, routers (N/A) */ - public processLinkPwrDelta(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): number { + public processLinkPwrDelta(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): void { const options = data.readUInt8(offset); offset += 1; - // 0 Notification An unsolicited notification. These frames are typically sent periodically from an RxOn device. If the device is a FFD, it is broadcast to all RxOn devices (0xfffd), and includes power information for all neighboring RxOn devices. If the device is an RFD with RxOn, it is sent unicast to its Parent, and includes only power information for the Parent device. - // 1 Request Typically used by sleepy RFD devices that do not receive the periodic Notifications from their Parent. The sleepy RFD will wake up periodically to send this frame to its Parent, including only the Parent’s power information in its payload. Upon receipt, the Parent sends a Response (Type = 2) as an indirect transmission, with only the RFD’s power information in its payload. After macResponseWaitTime, the RFD polls its Parent for the Response, before going back to sleep. Request commands are sent as unicast. Note: any device MAY send a Request to solicit a Response from another device. These commands SHALL be sent as unicast and contain only the power information for the destination device. If this command is received as a broadcast, it SHALL be discarded with no action. - // 2 Response This command is sent in response to a Request. Response commands are sent as unicast to the sender of the Request. The response includes only the power information for the requesting device. + // 0 Notification + // - An unsolicited notification. These frames are typically sent periodically from an RxOn device. + // If the device is a FFD, it is broadcast to all RxOn devices (0xfffd), and includes power information for all neighboring RxOn devices. + // If the device is an RFD with RxOn, it is sent unicast to its Parent, and includes only power information for the Parent device. + // 1 Request + // - Typically used by sleepy RFD devices that do not receive the periodic Notifications from their Parent. + // The sleepy RFD will wake up periodically to send this frame to its Parent, including only the Parent’s power information in its payload. + // Upon receipt, the Parent sends a Response (Type = 2) as an indirect transmission, with only the RFD’s power information in its payload. + // After macResponseWaitTime, the RFD polls its Parent for the Response, before going back to sleep. Request commands are sent as unicast. + // Note: any device MAY send a Request to solicit a Response from another device. + // These commands SHALL be sent as unicast and contain only the power information for the destination device. + // If this command is received as a broadcast, it SHALL be discarded with no action. + // 2 Response + // - This command is sent in response to a Request. + // Response commands are sent as unicast to the sender of the Request. + // The response includes only the power information for the requesting device. // 3 Reserved const type = options & ZigbeeNWKConsts.CMD_NWK_LINK_PWR_DELTA_TYPE_MASK; const count = data.readUInt8(offset); @@ -1776,7 +1765,7 @@ export class NWKHandler { for (let i = 0; i < count; i++) { const device = data.readUInt16LE(offset); offset += 2; - const delta = data.readUInt8(offset); + const delta = data.readInt8(offset); offset += 1; deltas.push({ device, delta }); @@ -1787,20 +1776,108 @@ export class NWKHandler { `<=== NWK LINK_PWR_DELTA[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} type=${type} deltas=${deltas}]`, NS, ); + + // TODO: adjust power // TODO + // if (type === ZigbeeNWKConsts.CMD_NWK_LINK_PWR_DELTA_TYPE_REQUEST) { + // if (nwkHeader.source16 === undefined || nwkHeader.source16 >= ZigbeeConsts.BCAST_MIN) { + // return; // per spec, discard + // } + + // const source64 = this.#context.address16ToAddress64.get(nwkHeader.source16); + + // if (source64 === undefined) { + // return; // per spec, drop + // } + + // const device = this.#context.deviceTable.get(source64); - return offset; + // if (device === undefined) { + // return; // per spec, drop + // } + + // // The Power Delta to be included for each device in the Power List SHALL be + // // the difference in dBm between the optimal level (, see Annex D.9.2.4.2) and the last available RSSI for that device. + // const lastReceivedRssi = device.lastReceivedRssi; + + // if (lastReceivedRssi === undefined) { + // return; + // } + + // // per spec, defined as 20 dB above the sensitivity requirement + // const optimalLevel = ?? + 20; + + // await this.sendLinkPwrDelta(nwkHeader.source16, ZigbeeNWKConsts.CMD_NWK_LINK_PWR_DELTA_TYPE_RESPONSE, [ + // { device: nwkHeader.source16, delta: optimalLevel - lastReceivedRssi }, + // ]); + // } } - // TODO: sendLinkPwrDelta + /** + * 06-3474-23 #3.4.13 + * + * Per spec, `nwkDest16` shall be `BCAST_RX_ON_WHEN_IDLE` when type is `CMD_NWK_LINK_PWR_DELTA_TYPE_NOTIFICATION` + * and only unicast when type is `CMD_NWK_LINK_PWR_DELTA_TYPE_RESPONSE`. + * + * SPEC COMPLIANCE: + * - TODO + * + * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) + */ + public async sendLinkPwrDelta( + nwkDest16: number, + type: ZigbeeNWKConsts.CMD_NWK_LINK_PWR_DELTA_TYPE_NOTIFICATION | ZigbeeNWKConsts.CMD_NWK_LINK_PWR_DELTA_TYPE_RESPONSE, + deltas: { device: number; delta: number }[], + ): Promise { + logger.debug(() => { + let deltasStr = ""; + + for (const { device, delta } of deltas) { + deltasStr += `{${device}|delta:${delta}}`; + } + + return `===> NWK LINK_PWR_DELTA[deltas=${deltasStr}]`; + }, NS); + const deltasLen = deltas.length; + const maxDeltasPayloadSize = ZigbeeNWKConsts.PAYLOAD_MIN_SIZE - 3; // cmdId[1] + type[1] + deltasLen[1] + const maxDeltasPerFrame = (maxDeltasPayloadSize / 3) | 0; + let deltasOffset = 0; + + do { + const deltaCount = Math.min(maxDeltasPerFrame, deltasLen - deltasOffset); + const finalPayload = Buffer.allocUnsafe(3 + deltaCount * 3); + let fpOffset = 0; + fpOffset = finalPayload.writeUInt8(ZigbeeNWKCommandId.LINK_PWR_DELTA, fpOffset); + fpOffset = finalPayload.writeUInt8(type, fpOffset); + fpOffset = finalPayload.writeUInt8(deltaCount, fpOffset); + + for (let i = 0; i < deltaCount; i++) { + const { device, delta } = deltas[deltasOffset + i]; + fpOffset = finalPayload.writeUInt16LE(device, fpOffset); + fpOffset = finalPayload.writeInt8(delta, fpOffset); + } + + // TODO: jitter + await this.sendCommand( + ZigbeeNWKCommandId.LINK_PWR_DELTA, + finalPayload, + false, // nwkSecurity + ZigbeeConsts.COORDINATOR_ADDRESS, // nwkSource16 + nwkDest16, // nwkDest16 + nwkDest16 >= ZigbeeConsts.BCAST_MIN ? undefined : this.#context.address16ToAddress64.get(nwkDest16), // nwkDest64 + 1, // nwkRadius + ); + + deltasOffset += deltaCount; + } while (deltasOffset < deltasLen); + } /** - * 05-3474-23 #3.4.14 - * Optional + * 06-3474-23 #3.4.14 + * Optional: R23+ * * SPEC COMPLIANCE NOTES: * - ✅ Correctly decodes assocType and capabilities - * - ⚠️ TODO: TLVs not decoded (may contain critical R23+ commissioning info) * - ✅ Determines initial join vs rejoin from assocType: * - 0x00 = Initial Join ✅ * - 0x01 = Rejoin ✅ @@ -1808,9 +1885,7 @@ export class NWKHandler { * - ✅ Calls context associate with appropriate parameters * - ✅ Sends COMMISSIONING_RESPONSE with status and address * - ✅ Sends TRANSPORT_KEY_NWK on SUCCESS when required - * - ⚠️ MISSING: No validation of commissioning TLVs - * - TLVs may contain security parameters - * - Should validate and process these + * - ✅ Commissioning TLVs (R23+) * - ⚠️ SPEC NOTE: Comment about sending Remove Device CMD to deny join * - Alternative to normal rejection mechanism * - Not implemented here @@ -1827,22 +1902,49 @@ export class NWKHandler { * @param nwkHeader NWK header * @returns New offset after processing */ - public async processCommissioningRequest(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): Promise { - // 0x00 Initial Join - // 0x01 Rejoin - const assocType = data.readUInt8(offset); + public async processCommissioningRequest(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): Promise { + // ZigbeeNWKCommissioningType + const commissioningType = data.readUInt8(offset); offset += 1; + const initialJoin = commissioningType === ZigbeeNWKCommissioningType.INITIAL_JOIN; + + if (nwkHeader.frameControl.security && initialJoin) { + // per spec, drop + return; + } + const capabilities = data.readUInt8(offset); offset += 1; - const decodedCap = decodeMACCapabilities(capabilities); + const [globalTlvs] = readZigbeeTlvs(data, offset); + const joinerTlv = globalTlvs[GlobalTlv.JOINER_ENCAPSULATION]; + let selectedKeyNegotiationMethod = GlobalTlvConsts.KEY_NEGOTATION_METHOD_STATIC; // fallback - // TODO - // const [tlvs, tlvsOutOffset] = decodeZigbeeNWKTLVs(data, offset); + if (joinerTlv !== undefined) { + // device is R23 + const fragmentationParametersTlv = joinerTlv.additionalTlvs[GlobalTlv.FRAGMENTATION_PARAMETERS]; + const supportedKeyNegotiationMethodsTlv = joinerTlv.additionalTlvs[GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS]; + + if (fragmentationParametersTlv !== undefined) { + // TODO + } + + if (supportedKeyNegotiationMethodsTlv !== undefined) { + // TODO + const bitmask = supportedKeyNegotiationMethodsTlv.keyNegotiationProtocolsBitmask; + + // TODO: by order of "most security"? + if (bitmask & GlobalTlvConsts.KEY_NEGOTATION_METHOD_SHA256) { + selectedKeyNegotiationMethod = GlobalTlvConsts.KEY_NEGOTATION_METHOD_SHA256; + } else if (bitmask & GlobalTlvConsts.KEY_NEGOTATION_METHOD_MMO128) { + selectedKeyNegotiationMethod = GlobalTlvConsts.KEY_NEGOTATION_METHOD_MMO128; + } + } + } logger.debug( () => - `<=== NWK COMMISSIONING_REQUEST[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} assocType=${assocType} cap=${capabilities}]`, + `<=== NWK COMMISSIONING_REQUEST[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} type=${commissioningType} cap=${capabilities}]`, NS, ); @@ -1851,7 +1953,7 @@ export class NWKHandler { const [status, newAddress16, requiresTransportKey] = await this.#context.associate( nwkHeader.source16!, nwkHeader.source64, - assocType === 0x00 /* initial join */, + initialJoin, decodedCap, macHeader.source16 === nwkHeader.source16, nwkHeader.frameControl.security /* deny if true */, @@ -1860,20 +1962,23 @@ export class NWKHandler { await this.sendCommissioningResponse(nwkHeader.source16!, newAddress16, status); if (status === MACAssociationStatus.SUCCESS) { - const dest64 = this.#context.address16ToAddress64.get(newAddress16); - - if (dest64 !== undefined && requiresTransportKey) { - await this.#callbacks.onAPSSendTransportKeyNWK( - newAddress16, - this.#context.netParams.networkKey, - this.#context.netParams.networkKeySequenceNumber, - dest64, - ); - this.#context.markNetworkKeyTransported(dest64); + // TODO: might need to be different if R23 or not + if (selectedKeyNegotiationMethod === GlobalTlvConsts.KEY_NEGOTATION_METHOD_STATIC) { + const dest64 = this.#context.address16ToAddress64.get(newAddress16); + + if (dest64 !== undefined && requiresTransportKey) { + await this.#callbacks.onAPSSendTransportKeyNWK( + newAddress16, + this.#context.netParams.networkKey, + this.#context.netParams.networkKeySequenceNumber, + dest64, + ); + this.#context.markNetworkKeyTransported(dest64); + } + } else { + // TODO START_KEY_UPDATE ZDO } } - - return offset; } // NOTE: sendCommissioningRequest DEVICE SCOPE: routers (N/A), end devices (N/A) @@ -1881,7 +1986,7 @@ export class NWKHandler { // NOTE: processCommissioningResponse DEVICE SCOPE: routers (N/A), end devices (N/A) /** - * 05-3474-23 #3.4.15 (Commissioning Response) — Optional in R23 + * 06-3474-23 #3.4.15 (Commissioning Response) — Optional in R23 * * SPEC COMPLIANCE NOTES: * - ✅ Sends commissioning response with STATUS + new address fields as defined in Table 3-22 diff --git a/src/zigbee-stack/stack-context.ts b/src/zigbee-stack/stack-context.ts index a624277..3a354ba 100644 --- a/src/zigbee-stack/stack-context.ts +++ b/src/zigbee-stack/stack-context.ts @@ -86,7 +86,7 @@ export enum NetworkKeyUpdateMethod { } /** - * see 05-3474-23 #4.7.3 + * see 06-3474-23 #4.7.3 */ export type TrustCenterPolicies = { /** @@ -151,6 +151,8 @@ export type DeviceTableEntry = { * Note: this is runtime-only */ recentLQAs: number[]; + /** Note: this is runtime-only */ + lastReceivedRssi: number | undefined; /** Last accepted NWK security frame counter. Runtime-only. */ incomingNWKFrameCounter: number | undefined; /** End device timeout metadata. Runtime-only. */ @@ -163,7 +165,7 @@ export type DeviceTableEntry = { } | undefined; /** Counter for consecutive missed link status commands. Runtime-only. */ - linkStatusMisses: number | undefined; + linkStatusMisses: number; }; export type SourceRouteTableEntry = { @@ -186,7 +188,7 @@ export type AppLinkKeyStoreEntry = { }; /** - * 05-3474-23 #2.5.5 + * 06-3474-23 #2.5.5 */ export type ConfigurationAttributes = { /** @@ -194,7 +196,7 @@ export type ConfigurationAttributes = { */ address: Buffer; /** - * 05-3474-23 #2.3.2.3 + * 06-3474-23 #2.3.2.3 * The :Config_Node_Descriptor is either created when the application is first loaded or initialized with a commissioning tool prior to when the device begins operations in the network. * It is used for service discovery to describe node features to external inquiring devices. * @@ -205,7 +207,7 @@ export type ConfigurationAttributes = { */ nodeDescriptor: Buffer; /** - * 05-3474-23 #2.3.2.4 + * 06-3474-23 #2.3.2.4 * The :Config_Power_Descriptor is either created when the application is first loaded or initialized with a commissioning tool prior to when the device begins operations in the network. * It is used for service discovery to describe node power features to external inquiring devices. * @@ -216,7 +218,7 @@ export type ConfigurationAttributes = { */ powerDescriptor: Buffer; /** - * 05-3474-23 #2.3.2.5 + * 06-3474-23 #2.3.2.5 * The :Config_Simple_Descriptors are created when the application is first loaded and are treated as “read-only.” * The Simple Descriptor are used for service discovery to describe interfacing features to external inquiring devices. * @@ -262,12 +264,12 @@ export type ConfigurationAttributes = { */ // nwkSecureAllFrames: number; // optional /** - * 05-3474-23 Table 2-134 + * 06-3474-23 Table 2-134 * The value for this configuration attribute is established in the Stack Profile. */ // nwkBroadcastDeliveryTime: number; // optional /** - * 05-3474-23 Table 2-134 + * 06-3474-23 Table 2-134 * The value for this configuration attribute is established in the Stack Profile. * This attribute is mandatory for the Zigbee coordinator and Zigbee routers and not used for Zigbee End Devices. */ @@ -280,7 +282,7 @@ export type ConfigurationAttributes = { */ // maxAssoc: number; // optional /** - * 05-3474-23 #3.2.2.16 + * 06-3474-23 #3.2.2.16 * :Config_NWK_Join_Direct_Addrs permits the Zigbee Coordinator or Router to be pre-configured with a list of addresses to be direct joined. * Consists of the following fields: * - DeviceAddress - 64-bit IEEE address for the device to be direct joined. @@ -337,7 +339,7 @@ export interface StackContextCallbacks { onDeviceLeft: StackCallbacks["onDeviceLeft"]; } -/** Table 3-54 */ +/** Table 3-58 */ export const END_DEVICE_TIMEOUT_TABLE_MS = [ 10_000, 2 * 60 * 1000, @@ -422,7 +424,7 @@ export class StackContext { /** MAC association permit flag */ associationPermit = false; - //---- Trust Center (see 05-3474-23 #4.7.1) + //---- Trust Center (see 06-3474-23 #4.7.1) #allowJoinTimeout: NodeJS.Timeout | undefined; @@ -454,7 +456,7 @@ export class StackContext { // #endregion /** - * 05-3474-23 #4.7.6 (Trust Center maintenance) + * 06-3474-23 #4.7.6 (Trust Center maintenance) * * SPEC COMPLIANCE NOTES: * - ✅ Schedules periodic state persistence while stack is running @@ -470,7 +472,7 @@ export class StackContext { } /** - * 05-3474-23 #4.7.6 (Trust Center maintenance) + * 06-3474-23 #4.7.6 (Trust Center maintenance) * * SPEC COMPLIANCE NOTES: * - ✅ Cancels pending timers and ensures join window closed on shutdown @@ -485,7 +487,7 @@ export class StackContext { } /** - * 05-3474-23 #4.7.6 (Trust Center maintenance) + * 06-3474-23 #4.7.6 (Trust Center maintenance) * * Remove the save file and clear tables (just in case) * @@ -533,7 +535,7 @@ export class StackContext { } /** - * 05-3474-23 #4.4.11.2 (Network Key transport) + * 06-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. * @@ -563,7 +565,7 @@ export class StackContext { } /** - * 05-3474-23 #4.4.11.5 (Switch Key) + * 06-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. @@ -629,7 +631,7 @@ export class StackContext { // } /** - * 05-3474-23 #3.6.1.10 (Network address allocation) + * 06-3474-23 #3.6.1.10 (Network address allocation) * * SPEC COMPLIANCE NOTES: * - ✅ Allocates short addresses within 0x0001-0xfff7 range, excluding coordinator and broadcast values @@ -652,7 +654,7 @@ export class StackContext { } /** - * 05-3474-23 #3.6.1.11 / Table 3-54 (End Device Timeout) + * 06-3474-23 #3.6.1.11 / Table 3-54 (End Device Timeout) * * Update the stored end device timeout metadata for a device. * @@ -693,7 +695,7 @@ export class StackContext { } /** - * 05-3474-23 #3.7.3 (NWK security) / IEEE 802.15.4-2015 #9.4.2 + * 06-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. * @@ -774,7 +776,7 @@ export class StackContext { } /** - * 05-3474-23 #3.3.4.3 (Link Quality Assessment) + * 06-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 @@ -811,7 +813,7 @@ export class StackContext { } /** - * 05-3474-23 #2.4.4.2.3 (Neighbor table reporting) + * 06-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. @@ -851,6 +853,7 @@ export class StackContext { } if (signalStrength !== undefined) { + device.lastReceivedRssi = signalStrength; const lqa = this.computeLQA(signalStrength, signalQuality); if (device.recentLQAs.length > maxRecent) { @@ -880,7 +883,7 @@ export class StackContext { } /** - * 05-3474-23 #3.3.1.5 (NWK radius handling) + * 06-3474-23 #3.3.1.5 (NWK radius handling) * * Decrement radius value for NWK frame forwarding. * HOT PATH: Optimized computation @@ -914,7 +917,7 @@ export class StackContext { } /** - * 05-3474-23 #4.4.11 (Trust Center link/app keys) + * 06-3474-23 #4.4.11 (Trust Center link/app keys) * * SPEC COMPLIANCE NOTES: * - ✅ Retrieves stored application/link key using canonicalized IEEE pair @@ -933,7 +936,7 @@ export class StackContext { } /** - * 05-3474-23 #4.4.11.1 (Application Link Key establishment) + * 06-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 @@ -953,7 +956,7 @@ export class StackContext { } /** - * 05-3474-23 #4.5.1 (Install Code processing) + * 06-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) @@ -1002,7 +1005,7 @@ export class StackContext { } /** - * 05-3474-23 #4.5.1 (Install Code lifecycle) + * 06-3474-23 #4.5.1 (Install Code lifecycle) * * SPEC COMPLIANCE NOTES: * - ✅ Removes stored install code metadata upon revocation @@ -1015,7 +1018,7 @@ export class StackContext { } /** - * 05-3474-23 #4.7.6 (Trust Center persistent data) + * 06-3474-23 #4.7.6 (Trust Center persistent data) * * Save state to file system in TLV format. * @@ -1096,7 +1099,7 @@ export class StackContext { } /** - * 05-3474-23 #4.7.6 (Trust Center persistent data) + * 06-3474-23 #4.7.6 (Trust Center persistent data) * * Read the current network state in the save file, if any present. * @@ -1131,7 +1134,7 @@ export class StackContext { } /** - * 05-3474-23 #4.7.6 (Trust Center start-up procedure) + * 06-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. @@ -1179,6 +1182,7 @@ export class StackContext { neighbor, lastTransportedNetworkKeySeq, recentLQAs: [], + lastReceivedRssi: undefined, incomingNWKFrameCounter: undefined, // TODO: record this (should persist across reboots) endDeviceTimeout: undefined, linkStatusMisses: 0, // will stay zero for RFDs @@ -1236,7 +1240,7 @@ export class StackContext { } /** - * 05-3474-23 #2.3.2.3 (Node Descriptor) + * 06-3474-23 #2.3.2.3 (Node Descriptor) * * Set the manufacturer code in the pre-encoded node descriptor * @@ -1252,7 +1256,7 @@ export class StackContext { } /** - * 05-3474-23 #4.7.6 (Trust Center maintenance) + * 06-3474-23 #4.7.6 (Trust Center maintenance) * * SPEC COMPLIANCE NOTES: * - ✅ Persists state at configured interval while refreshing timer to maintain cadence @@ -1488,9 +1492,10 @@ export class StackContext { neighbor, lastTransportedNetworkKeySeq: undefined, recentLQAs: [], + lastReceivedRssi: undefined, incomingNWKFrameCounter: undefined, endDeviceTimeout: undefined, - linkStatusMisses: 0, // will stay zero for RFDs + linkStatusMisses: 0, }); this.address16ToAddress64.set(newAddress16, source64!); diff --git a/src/zigbee/mac.ts b/src/zigbee/mac.ts index 6ec0f29..4e83ea8 100644 --- a/src/zigbee/mac.ts +++ b/src/zigbee/mac.ts @@ -383,7 +383,7 @@ export function getMICLength(securityLevel: number): number { * Decode MAC frame control field. * HOT PATH: Called for every incoming MAC frame. * IEEE Std 802.15.4-2020, 7.2.1 (Frame control field) - * 05-3474-23 R23.1, Annex C.2 (Zigbee MAC frame subset) + * 06-3474-23 R23.1, Annex C.2 (Zigbee MAC frame subset) * * SPEC COMPLIANCE NOTES: * - ✅ Parses Zigbee-required FCF bits and reconstructs addressing/security flags @@ -423,7 +423,7 @@ export function decodeMACFrameControl(data: Buffer, offset: number): [MACFrameCo /** * IEEE Std 802.15.4-2020, 7.2.1 (Frame control field) - * 05-3474-23 R23.1, Annex C.2 (Zigbee MAC frame subset) + * 06-3474-23 R23.1, Annex C.2 (Zigbee MAC frame subset) * * SPEC COMPLIANCE NOTES: * - ✅ Emits only Zigbee-allowed frame versions and addressing combinations @@ -457,7 +457,7 @@ function encodeMACFrameControl(data: Buffer, offset: number, fcf: MACFrameContro /** * IEEE Std 802.15.4-2020, 9.4.2 (Auxiliary security header) - * 05-3474-23 R23.1, Annex C.4 (Zigbee MAC security profile) + * 06-3474-23 R23.1, Annex C.4 (Zigbee MAC security profile) * * SPEC COMPLIANCE NOTES: * - ✅ Parses key identifier modes used for Zigbee MAC security interop @@ -510,7 +510,7 @@ function decodeMACAuxSecHeader(data: Buffer, offset: number): [MACAuxSecHeader, /** * IEEE Std 802.15.4-2020, 7.3.1 (Superframe specification field) - * 05-3474-23 R23.1, 2.2.2.5 (Beacon superframe descriptor) + * 06-3474-23 R23.1, 2.2.2.5 (Beacon superframe descriptor) * * SPEC COMPLIANCE NOTES: * - ✅ Decodes beacon order, CAP slot, and association permit for Trust Center policy @@ -543,7 +543,7 @@ function decodeMACSuperframeSpec(data: Buffer, offset: number): [MACSuperframeSp /** * IEEE Std 802.15.4-2020, 7.3.1 (Superframe specification field) - * 05-3474-23 R23.1, 2.2.2.5 (Beacon superframe descriptor) + * 06-3474-23 R23.1, 2.2.2.5 (Beacon superframe descriptor) * * SPEC COMPLIANCE NOTES: * - ✅ Encodes Zigbee beacon superframe bits with spec-defined masks and shifts @@ -568,7 +568,7 @@ function encodeMACSuperframeSpec(data: Buffer, offset: number, header: MACHeader /** * IEEE Std 802.15.4-2020, 7.3.2 (GTS fields) - * 05-3474-23 R23.1, Annex C.5 (Zigbee beacon GTS usage) + * 06-3474-23 R23.1, Annex C.5 (Zigbee beacon GTS usage) * * SPEC COMPLIANCE NOTES: * - ✅ Parses GTS slot descriptors and permit flag to support indirect transmissions @@ -626,7 +626,7 @@ function decodeMACGtsInfo(data: Buffer, offset: number): [MACGtsInfo, offset: nu /** * IEEE Std 802.15.4-2020, 7.3.2 (GTS fields) - * 05-3474-23 R23.1, Annex C.5 (Zigbee beacon GTS usage) + * 06-3474-23 R23.1, Annex C.5 (Zigbee beacon GTS usage) * * SPEC COMPLIANCE NOTES: * - ✅ Emits GTS descriptors in canonical order for Zigbee beacon compliance @@ -660,7 +660,7 @@ function encodeMACGtsInfo(data: Buffer, offset: number, header: MACHeader): numb /** * IEEE Std 802.15.4-2020, 7.3.3 (Pending address specification) - * 05-3474-23 R23.1, Annex C.6 (Indirect data poll support) + * 06-3474-23 R23.1, Annex C.6 (Indirect data poll support) * * SPEC COMPLIANCE NOTES: * - ✅ Extracts short and extended pending address lists for Zigbee indirect transmissions @@ -707,7 +707,7 @@ function decodeMACPendAddr(data: Buffer, offset: number): [MACPendAddr, offset: /** * IEEE Std 802.15.4-2020, 7.3.3 (Pending address specification) - * 05-3474-23 R23.1, Annex C.6 (Indirect data poll support) + * 06-3474-23 R23.1, Annex C.6 (Indirect data poll support) * * SPEC COMPLIANCE NOTES: * - ✅ Serialises pending address lists using Zigbee-ordered masks @@ -736,7 +736,7 @@ function encodeMACPendAddr(data: Buffer, offset: number, header: MACHeader): num } /** - * 05-3474-23 R23.1, Table 2-36 (MAC capability information field) + * 06-3474-23 R23.1, Table 2-36 (MAC capability information field) * * SPEC COMPLIANCE NOTES: * - ✅ Maps capability bits used during association joins (device type, power, security) @@ -758,7 +758,7 @@ export function decodeMACCapabilities(capabilities: number): MACCapabilities { } /** - * 05-3474-23 R23.1, Table 2-36 (MAC capability information field) + * 06-3474-23 R23.1, Table 2-36 (MAC capability information field) * * SPEC COMPLIANCE NOTES: * - ✅ Encodes capability flags in Zigbee-defined bit order for association responses @@ -781,7 +781,7 @@ export function encodeMACCapabilities(capabilities: MACCapabilities): number { /** * IEEE Std 802.15.4-2020, 7.2.2 (MAC header fields) - * 05-3474-23 R23.1, Annex C.2 (Zigbee MAC frame subset) + * 06-3474-23 R23.1, Annex C.2 (Zigbee MAC frame subset) * * Decode MAC header from frame. * HOT PATH: Called for every incoming MAC frame. @@ -953,7 +953,7 @@ export function decodeMACHeader(data: Buffer, offset: number, frameControl: MACF /** * IEEE Std 802.15.4-2020, 7.2.2 (MAC header fields) - * 05-3474-23 R23.1, Annex C.2 (Zigbee MAC frame subset) + * 06-3474-23 R23.1, Annex C.2 (Zigbee MAC frame subset) * * SPEC COMPLIANCE NOTES: * - ✅ Constructs headers matching Zigbee addressing and PAN compression rules @@ -1116,7 +1116,7 @@ function crc16CCITT(data: Buffer): number { /** * IEEE Std 802.15.4-2020, 7.2.2.4 (MAC payload and FCS handling) - * 05-3474-23 R23.1, Annex C.2 (Zigbee MAC frame subset) + * 06-3474-23 R23.1, Annex C.2 (Zigbee MAC frame subset) * * SPEC COMPLIANCE NOTES: * - ✅ Rejects MAC-layer security frames in line with Zigbee host design (security handled at NWK/APS) @@ -1145,7 +1145,7 @@ export function decodeMACPayload(data: Buffer, offset: number, frameControl: MAC /** * IEEE Std 802.15.4-2020, 7.2.2 (General MAC frame format) - * 05-3474-23 R23.1, Annex C.2 (Zigbee MAC frame subset) + * 06-3474-23 R23.1, Annex C.2 (Zigbee MAC frame subset) * * SPEC COMPLIANCE NOTES: * - ✅ Emits canonical MAC frames with Zigbee-safe payload length and CRC tail @@ -1185,7 +1185,7 @@ export type MACHeaderZigbee = { /** * Encode MAC frame with hotpath for Zigbee NWK/APS payload - * 05-3474-23 R23.1, Annex C.2.1 (Zigbee data frame rules) + * 06-3474-23 R23.1, Annex C.2.1 (Zigbee data frame rules) * * SPEC COMPLIANCE NOTES: * - ✅ Forces frame version to 2003 as mandated for Zigbee data/command frames @@ -1197,7 +1197,7 @@ export function encodeMACFrameZigbee(header: MACHeaderZigbee, payload: Buffer): let offset = 0; const data = Buffer.allocUnsafe(ZigbeeMACConsts.PAYLOAD_MAX_SIZE); // TODO: optimize with max Zigbee header length - // always transmit with v2003 (0) frame version @see D.6 Frame Version Value of 05-3474-23 + // always transmit with v2003 (0) frame version @see D.6 Frame Version Value of 06-3474-23 header.frameControl.frameVersion = MACFrameVersion.V2003; offset = encodeMACHeader(data, offset, header, true); // zigbee hotpath @@ -1226,7 +1226,7 @@ export type MACZigbeeBeacon = { }; /** - * 05-3474-23 R23.1, 2.2.2 (Beacon payload format) + * 06-3474-23 R23.1, 2.2.2 (Beacon payload format) * * SPEC COMPLIANCE NOTES: * - ✅ Parses routing and end-device capacity bits for join admission logic @@ -1270,7 +1270,7 @@ export function decodeMACZigbeeBeacon(data: Buffer, offset: number): MACZigbeeBe } /** - * 05-3474-23 R23.1, 2.2.2 (Beacon payload format) + * 06-3474-23 R23.1, 2.2.2 (Beacon payload format) * * SPEC COMPLIANCE NOTES: * - ✅ Serialises Zigbee beacon descriptor using mandated masks and shifts @@ -1313,7 +1313,7 @@ export function encodeMACZigbeeBeacon(beacon: Omit): number { offset = data.writeUInt8(GlobalTlv.ROUTER_INFORMATION, offset); offset = data.writeUInt8(1, offset); // per spec, actual data length is `length field + 1` - offset = data.writeUInt16LE(tlv.bitmap, offset); + offset = data.writeUInt16LE(tlv.bitmask, offset); return offset; } diff --git a/src/zigbee/zigbee-aps.ts b/src/zigbee/zigbee-aps.ts index cf69660..3b536d5 100644 --- a/src/zigbee/zigbee-aps.ts +++ b/src/zigbee/zigbee-aps.ts @@ -138,7 +138,7 @@ export type ZigbeeAPSPayload = Buffer; /** * Decode Zigbee APS frame control field. * HOT PATH: Called for every incoming Zigbee APS frame. - * 05-3474-23 R23.1, Table 2-69 (APS frame control fields) + * 06-3474-23 R23.1, Table 2-69 (APS frame control fields) * * SPEC COMPLIANCE NOTES: * - ✅ Extracts frame type, delivery, security, and extended header bits per Zigbee 4.0 profile @@ -167,7 +167,7 @@ export function decodeZigbeeAPSFrameControl(data: Buffer, offset: number): [Zigb } /** - * 05-3474-23 R23.1, Table 2-69 (APS frame control fields) + * 06-3474-23 R23.1, Table 2-69 (APS frame control fields) * * SPEC COMPLIANCE NOTES: * - ✅ Encodes frame control bits according to Zigbee APS data/command frame requirements @@ -191,7 +191,7 @@ function encodeZigbeeAPSFrameControl(data: Buffer, offset: number, fcf: ZigbeeAP } /** - * 05-3474-23 R23.1, Tables 2-69/2-70 (APS data and command frame formats) + * 06-3474-23 R23.1, Tables 2-69/2-70 (APS data and command frame formats) * * SPEC COMPLIANCE NOTES: * - ✅ Applies delivery-mode driven presence rules for endpoints, groups, cluster/profile IDs @@ -311,7 +311,7 @@ export function decodeZigbeeAPSHeader(data: Buffer, offset: number, frameControl } /** - * 05-3474-23 R23.1, Tables 2-69/2-70 (APS data and command frame formats) + * 06-3474-23 R23.1, Tables 2-69/2-70 (APS data and command frame formats) * * SPEC COMPLIANCE NOTES: * - ✅ Serialises endpoint/group fields following delivery-mode matrix mandated by spec @@ -402,7 +402,7 @@ export function encodeZigbeeAPSHeader(data: Buffer, offset: number, header: Zigb } /** - * 05-3474-23 R23.1, Annex B (APS security processing) + * 06-3474-23 R23.1, Annex B (APS security processing) * * SPEC COMPLIANCE NOTES: * - ✅ Invokes APS encryption/decryption helpers when security bit is asserted, per spec flow @@ -437,7 +437,7 @@ export function decodeZigbeeAPSPayload( } /** - * 05-3474-23 R23.1, Table 2-70 (APS data frame format) & Annex B (APS security) + * 06-3474-23 R23.1, Table 2-70 (APS data frame format) & Annex B (APS security) * * SPEC COMPLIANCE NOTES: * - ✅ Builds full APS frame, invoking security helper when security flag set diff --git a/src/zigbee/zigbee-nwk.ts b/src/zigbee/zigbee-nwk.ts index 322fd2c..8781fc4 100644 --- a/src/zigbee/zigbee-nwk.ts +++ b/src/zigbee/zigbee-nwk.ts @@ -82,6 +82,9 @@ export const enum ZigbeeNWKConsts { //---- Zigbee NWK Link Power Delta Options CMD_NWK_LINK_PWR_DELTA_TYPE_MASK = 0x03, + CMD_NWK_LINK_PWR_DELTA_TYPE_NOTIFICATION = 0x0, + CMD_NWK_LINK_PWR_DELTA_TYPE_REQUEST = 0x1, + CMD_NWK_LINK_PWR_DELTA_TYPE_RESPONSE = 0x2, //---- MAC Association Status extension ASSOC_STATUS_ADDR_CONFLICT = 0xf0, @@ -122,17 +125,6 @@ export const enum ZigbeeNWKRouteDiscovery { FORCE = 0x0003, } -export const enum ZigbeeNWKMulticastMode { - NONMEMBER = 0x00, - MEMBER = 0x01, -} - -export const enum ZigbeeNWKRelayType { - NO_RELAY = 0, - RELAY_UPSTREAM = 1, - RELAY_DOWNSTREAM = 2, -} - /** Zigbee NWK Command Types */ export const enum ZigbeeNWKCommandId { /* Route Request Command. */ @@ -167,6 +159,13 @@ export const enum ZigbeeNWKCommandId { COMMISSIONING_RESPONSE = 0x0f, } +/** Types of Network Commissioning */ +export const enum ZigbeeNWKCommissioningType { + INITIAL_JOIN = 0x00, + REJOIN = 0x01, + ESTABLISH_TRUSTED_LINK = 0x02, +} + /** Network Status Code Definitions. */ export enum ZigbeeNWKStatus { /** @deprecated in R23, should no longer be sent, but still processed (same as @see LINK_FAILURE ) */ @@ -286,7 +285,7 @@ export type ZigbeeNWKPayload = Buffer; /** * Decode Zigbee NWK frame control field. * HOT PATH: Called for every incoming Zigbee NWK frame. - * 05-3474-23 R23.1, Table 3-19 (NWK frame control field) + * 06-3474-23 R23.1, Table 3-19 (NWK frame control field) * * SPEC COMPLIANCE NOTES: * - ✅ Extracts protocol version, discover route, source route, and security bits per Zigbee PRO @@ -317,7 +316,7 @@ export function decodeZigbeeNWKFrameControl(data: Buffer, offset: number): [Zigb } /** - * 05-3474-23 R23.1, Table 3-19 (NWK frame control field) + * 06-3474-23 R23.1, Table 3-19 (NWK frame control field) * * SPEC COMPLIANCE NOTES: * - ✅ Encodes NWK FCF bits honouring Zigbee PRO versioning and source route flags @@ -343,7 +342,7 @@ function encodeZigbeeNWKFrameControl(view: Buffer, offset: number, fcf: ZigbeeNW } /** - * 05-3474-23 R23.1, Tables 3-20/3-21 (NWK frame formats) + * 06-3474-23 R23.1, Tables 3-20/3-21 (NWK frame formats) * * SPEC COMPLIANCE NOTES: * - ✅ Applies frame-type specific field presence rules (extended addressing, source routes) @@ -421,7 +420,7 @@ export function decodeZigbeeNWKHeader(data: Buffer, offset: number, frameControl } /** - * 05-3474-23 R23.1, Tables 3-20/3-21 (NWK frame formats) + * 06-3474-23 R23.1, Tables 3-20/3-21 (NWK frame formats) * * SPEC COMPLIANCE NOTES: * - ✅ Serialises mandatory radius, sequence, and addressing fields for NWK data/command frames @@ -469,7 +468,7 @@ function encodeZigbeeNWKHeader(data: Buffer, offset: number, header: ZigbeeNWKHe * @param header */ /** - * 05-3474-23 R23.1, Annex A (NWK security) + * 06-3474-23 R23.1, Annex A (NWK security) * * SPEC COMPLIANCE NOTES: * - ✅ Invokes CCM* decrypt when security bit set, storing auxiliary header for Trust Center use @@ -503,7 +502,7 @@ export function decodeZigbeeNWKPayload( * @param encryptKey If undefined, and security=true, use default pre-hashed */ /** - * 05-3474-23 R23.1, Table 3-20 (NWK data frame format) & Annex A (NWK security) + * 06-3474-23 R23.1, Table 3-20 (NWK data frame format) & Annex A (NWK security) * * SPEC COMPLIANCE NOTES: * - ✅ Constructs NWK frame then encrypts/authenticates payload per Zigbee security flag diff --git a/src/zigbee/zigbee.ts b/src/zigbee/zigbee.ts index b781938..bd5a9f1 100644 --- a/src/zigbee/zigbee.ts +++ b/src/zigbee/zigbee.ts @@ -236,7 +236,7 @@ export function aes128MmoHash(data: Buffer): Buffer { } /** - * 05-3474-23 R23.1, Annex B/A (CCM* mode of operation) + * 06-3474-23 R23.1, Annex B/A (CCM* mode of operation) * * SPEC COMPLIANCE NOTES: * - ✅ Implements CCM* counter generation with L=2 for Zigbee network/APS security @@ -282,7 +282,7 @@ export function aes128CcmStar(M: 0 | 2 | 4 | 8 | 16, key: Buffer, nonce: Buffer, } /** - * 05-3474-23 R23.1, Annex B (CCM* authentication primitive) + * 06-3474-23 R23.1, Annex B (CCM* authentication primitive) * * SPEC COMPLIANCE NOTES: * - ✅ Builds B0 and authentication blocks per Zigbee CCM* definition using zero IV @@ -330,7 +330,7 @@ export function computeAuthTag(authData: Buffer, M: number, key: Buffer, nonce: } /** - * 05-3474-23 R23.1, Figure 4-25 (Security control field) + * 06-3474-23 R23.1, Figure 4-25 (Security control field) * * SPEC COMPLIANCE NOTES: * - ✅ Packs security level, key identifier, and nonce flag per Zigbee bit layout @@ -348,7 +348,7 @@ export function combineSecurityControl(control: ZigbeeSecurityControl, levelOver } /** - * 05-3474-23 R23.1, Annex B (Nonce construction) + * 06-3474-23 R23.1, Annex B (Nonce construction) * * SPEC COMPLIANCE NOTES: * - ✅ Orders IEEE source, frame counter, and security control bytes per Zigbee CCM* requirements @@ -389,7 +389,7 @@ export function registerDefaultHashedKeys(link: Buffer, nwk: Buffer, transport: } /** - * 05-3474-23 R23.1, Annex B.1.4 (Keyed hash for message authentication) + * 06-3474-23 R23.1, Annex B.1.4 (Keyed hash for message authentication) * * SPEC COMPLIANCE NOTES: * - ✅ Implements HMAC-like construction using AES-128-MMO with specified ipad/opad constants @@ -419,7 +419,7 @@ export function makeKeyedHash(key: Buffer, inputByte: number): Buffer { } /** - * 05-3474-23 R23.1, Annex B.1.5 (Key usage definitions) + * 06-3474-23 R23.1, Annex B.1.5 (Key usage definitions) * * SPEC COMPLIANCE NOTES: * - ✅ Returns unhashed NWK/LINK keys and hashed transport/load keys as mandated @@ -450,7 +450,7 @@ export function makeKeyedHashByType(keyId: ZigbeeKeyType, key: Buffer): Buffer { } /** - * 05-3474-23 R23.1, Table B-6 (Auxiliary security header) + * 06-3474-23 R23.1, Table B-6 (Auxiliary security header) * * SPEC COMPLIANCE NOTES: * - ✅ Parses frame counter, optional extended source, and key sequence per Zigbee security spec @@ -526,7 +526,7 @@ export function decodeZigbeeSecurityHeader(data: Buffer, offset: number, source6 } /** - * 05-3474-23 R23.1, Table B-6 (Auxiliary security header) + * 06-3474-23 R23.1, Table B-6 (Auxiliary security header) * * SPEC COMPLIANCE NOTES: * - ✅ Serialises security control, frame counter, optional IEEE source, and key sequence @@ -551,7 +551,7 @@ export function encodeZigbeeSecurityHeader(data: Buffer, offset: number, header: } /** - * 05-3474-23 R23.1, Annex B (Inbound security processing) + * 06-3474-23 R23.1, Annex B (Inbound security processing) * * SPEC COMPLIANCE NOTES: * - ✅ Applies CCM* decryption using hashed keys derived per key type definition @@ -602,7 +602,7 @@ export function decryptZigbeePayload( } /** - * 05-3474-23 R23.1, Annex B (Outbound security processing) + * 06-3474-23 R23.1, Annex B (Outbound security processing) * * SPEC COMPLIANCE NOTES: * - ✅ Computes MIC over NWK/APS header and encrypts payload per CCM* specification diff --git a/test/compliance/aps.test.ts b/test/compliance/aps.test.ts index 2ed6633..fdab2c2 100644 --- a/test/compliance/aps.test.ts +++ b/test/compliance/aps.test.ts @@ -3,7 +3,7 @@ * * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: - * - Zigbee specification (05-3474-23): Revision 23.1 + * - Zigbee specification (06-3474-23): Revision 23.1 * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 diff --git a/test/compliance/bdb.test.ts b/test/compliance/bdb.test.ts index 980337c..e4101a4 100644 --- a/test/compliance/bdb.test.ts +++ b/test/compliance/bdb.test.ts @@ -3,7 +3,7 @@ * * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: - * - Zigbee specification (05-3474-23): Revision 23.1 + * - Zigbee specification (06-3474-23): Revision 23.1 * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 @@ -45,7 +45,7 @@ import { NWKGPHandler, type NWKGPHandlerCallbacks } from "../../src/zigbee-stack import { NWKHandler, type NWKHandlerCallbacks } from "../../src/zigbee-stack/nwk-handler.js"; import { type NetworkParameters, StackContext, type StackContextCallbacks } from "../../src/zigbee-stack/stack-context.js"; import { NETDEF_EXTENDED_PAN_ID, NETDEF_NETWORK_KEY, NETDEF_PAN_ID, NETDEF_TC_KEY } from "../data.js"; -import { createMACFrameControl } from "../utils.js"; +import { createMACFrameControl, defaultDeviceTableEntry } from "../utils.js"; import { captureMacFrame, cloneNetworkParameters, decodeMACFramePayload, NO_ACK_CODE, TEST_DEVICE_EUI64 } from "./utils.js"; describe("Zigbee 4.0 Device Behavior Compliance", () => { @@ -223,7 +223,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { fcs: 0, }; - const decoded = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), 0, beaconReqHeader), mockMACHandlerCallbacks); + const decoded = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), beaconReqHeader), mockMACHandlerCallbacks); expect(decoded.frameControl.frameType).toStrictEqual(MACFrameType.BEACON); expect(decoded.header.superframeSpec?.panCoordinator).toStrictEqual(true); @@ -244,7 +244,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { fcs: 0, }; - const decoded = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), 0, beaconReqHeader), mockMACHandlerCallbacks); + const decoded = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), beaconReqHeader), mockMACHandlerCallbacks); expect(context.trustCenterPolicies.allowJoins).toStrictEqual(true); expect(decoded.header.superframeSpec?.associationPermit).toStrictEqual(true); @@ -318,6 +318,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { const sendCommandSpy = vi.spyOn(nwkHandler, "sendCommand"); context.deviceTable.set(router64, { + ...defaultDeviceTableEntry(), address16: router16, capabilities: { alternatePANCoordinator: false, @@ -328,12 +329,6 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { allocateAddress: true, }, authorized: true, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(router16, router64); @@ -382,6 +377,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { const destination64 = 0x00124b0000f5f6n; context.deviceTable.set(relay64, { + ...defaultDeviceTableEntry(), address16: relay16, capabilities: { alternatePANCoordinator: false, @@ -393,15 +389,11 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { }, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(relay16, relay64); context.deviceTable.set(destination64, { + ...defaultDeviceTableEntry(), address16: destination16, capabilities: { alternatePANCoordinator: false, @@ -412,12 +404,6 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { allocateAddress: true, }, authorized: true, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(destination16, destination64); @@ -458,6 +444,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { const router64 = 0x00124b0000f7f8n; context.deviceTable.set(router64, { + ...defaultDeviceTableEntry(), address16: router16, capabilities: { alternatePANCoordinator: false, @@ -468,12 +455,6 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { allocateAddress: true, }, authorized: true, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(router16, router64); @@ -538,6 +519,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { const sendCommandSpy = vi.spyOn(nwkHandler, "sendCommand"); context.deviceTable.set(neighbor64, { + ...defaultDeviceTableEntry(), address16: neighbor16, capabilities: { alternatePANCoordinator: false, @@ -549,11 +531,8 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { }, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, recentLQAs: [250, 245, 240], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, + lastReceivedRssi: -45, }); context.address16ToAddress64.set(neighbor16, neighbor64); @@ -623,7 +602,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { const capabilitiesByte = encodeMACCapabilities(capabilities); const assocHeader = buildAssocHeader(device64, source16); - await macHandler.processAssocReq(Buffer.from([capabilitiesByte]), 0, assocHeader); + await macHandler.processAssocReq(Buffer.from([capabilitiesByte]), assocHeader); const entry = context.deviceTable.get(device64); expect(entry).not.toBeUndefined(); @@ -717,7 +696,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { const sendCommandSpy = vi.spyOn(macHandler, "sendCommand").mockResolvedValue(true); mockMACHandlerCallbacks.onAPSSendTransportKeyNWK = vi.fn().mockResolvedValue(undefined); - await macHandler.processDataReq(Buffer.alloc(0), 0, buildDataRequestHeader(device64, assigned16)); + await macHandler.processDataReq(Buffer.alloc(0), buildDataRequestHeader(device64, assigned16)); expect(sendCommandSpy).toHaveBeenCalledTimes(1); const [, , , , assocPayload] = sendCommandSpy.mock.calls[0]!; @@ -759,7 +738,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { expect(queue).not.toBeUndefined(); expect(queue?.length).toStrictEqual(1); - await macHandler.processDataReq(Buffer.alloc(0), 0, buildDataRequestHeader(device64, assigned16)); + await macHandler.processDataReq(Buffer.alloc(0), buildDataRequestHeader(device64, assigned16)); expect(frames).toHaveLength(1); const nwkDecoded = decodeQueuedNWKCommand(frames[0]!); @@ -864,7 +843,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { const assigned16FromEntry = entry!.address16!; const dataReqHeader = buildDataRequestHeader(device64, assigned16FromEntry); - await macHandler.processDataReq(Buffer.alloc(0), 0, dataReqHeader); + await macHandler.processDataReq(Buffer.alloc(0), dataReqHeader); expect(context.indirectTransmissions.get(device64)).toStrictEqual([]); @@ -950,16 +929,13 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { fcs: 0, }; - const permitted = await captureMacFrame( - () => macHandler.processBeaconReq(Buffer.alloc(0), 0, beaconReqHeader), - mockMACHandlerCallbacks, - ); + const permitted = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), beaconReqHeader), mockMACHandlerCallbacks); expect(permitted.header.superframeSpec?.associationPermit).toStrictEqual(true); vi.advanceTimersByTime(2000); - const denied = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), 0, beaconReqHeader), mockMACHandlerCallbacks); + const denied = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), beaconReqHeader), mockMACHandlerCallbacks); expect(context.trustCenterPolicies.allowJoins).toStrictEqual(false); expect(denied.header.superframeSpec?.associationPermit).toStrictEqual(false); @@ -983,15 +959,11 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { }); context.deviceTable.set(device64, { + ...defaultDeviceTableEntry(), address16: device16, capabilities: undefined, authorized: false, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(device16, device64); @@ -1074,6 +1046,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { const device16 = 0x7aa4; context.deviceTable.set(device64, { + ...defaultDeviceTableEntry(), address16: device16, capabilities: { alternatePANCoordinator: false, @@ -1085,11 +1058,6 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { }, authorized: false, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(device16, device64); diff --git a/test/compliance/integration.test.ts b/test/compliance/integration.test.ts index 488c6ca..e024974 100644 --- a/test/compliance/integration.test.ts +++ b/test/compliance/integration.test.ts @@ -3,7 +3,7 @@ * * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: - * - Zigbee specification (05-3474-23): Revision 23.1 + * - Zigbee specification (06-3474-23): Revision 23.1 * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 @@ -237,7 +237,7 @@ describe("Integration and End-to-End Compliance", () => { }); // Step 1: Device requests beacon information and coordinator responds with association permit set. - await macHandler.processBeaconReq(Buffer.alloc(0), 0, buildBeaconRequestHeader(device64)); + await macHandler.processBeaconReq(Buffer.alloc(0), buildBeaconRequestHeader(device64)); expect(frames).toHaveLength(1); const beaconFrame = decodeMACFramePayload(frames[0]!); expect(beaconFrame.frameControl.frameType).toStrictEqual(MACFrameType.BEACON); @@ -245,7 +245,7 @@ describe("Integration and End-to-End Compliance", () => { expect(beaconFrame.header.superframeSpec?.associationPermit).toStrictEqual(true); // Step 2: Device issues association request with capabilities. - await macHandler.processAssocReq(Buffer.from([encodeMACCapabilities(deviceCapabilities)]), 0, buildAssocHeader(device64)); + await macHandler.processAssocReq(Buffer.from([encodeMACCapabilities(deviceCapabilities)]), buildAssocHeader(device64)); const entry = context.deviceTable.get(device64); expect(entry).not.toBeUndefined(); expect(entry?.capabilities).toStrictEqual(deviceCapabilities); @@ -253,7 +253,7 @@ describe("Integration and End-to-End Compliance", () => { expect(assigned16).not.toStrictEqual(0xffff); // Step 3: Device polls for data to receive the association response. - await macHandler.processDataReq(Buffer.alloc(0), 0, buildDataRequestHeader(device64, assigned16, 0x31)); + await macHandler.processDataReq(Buffer.alloc(0), buildDataRequestHeader(device64, assigned16, 0x31)); expect(frames).toHaveLength(2); const assocFrame = decodeMACFramePayload(frames[1]!); expect(assocFrame.frameControl.frameType).toStrictEqual(MACFrameType.CMD); @@ -264,7 +264,7 @@ describe("Integration and End-to-End Compliance", () => { expect(assocPayload.readUInt8(2)).toStrictEqual(MACAssociationStatus.SUCCESS); // Step 4: Coordinator delivers the network key via APS transport key on the next poll. - await macHandler.processDataReq(Buffer.alloc(0), 0, buildDataRequestHeader(device64, assigned16, 0x32)); + await macHandler.processDataReq(Buffer.alloc(0), buildDataRequestHeader(device64, assigned16, 0x32)); expect(frames).toHaveLength(3); const transportDecoded = decodeApsFromMac(frames[2]!); expect(transportDecoded.nwkFrameControl.frameType).toStrictEqual(ZigbeeNWKFrameType.DATA); @@ -770,7 +770,7 @@ describe("Integration and End-to-End Compliance", () => { await apsHandler.sendTransportKeyNWK(dest16, key, seqNum, dest64); }); - await macHandler.processAssocReq(Buffer.from([encodeMACCapabilities(deviceCapabilities)]), 0, buildAssocHeader(device64)); + await macHandler.processAssocReq(Buffer.from([encodeMACCapabilities(deviceCapabilities)]), buildAssocHeader(device64)); const pending = context.pendingAssociations.get(device64); expect(pending).not.toBeUndefined(); const deviceEntry = context.deviceTable.get(device64); @@ -779,8 +779,8 @@ describe("Integration and End-to-End Compliance", () => { const startCounter = context.netParams.tcKeyFrameCounter; - await macHandler.processDataReq(Buffer.alloc(0), 0, buildDataRequestHeader(device64, assigned16, 0x41)); - await macHandler.processDataReq(Buffer.alloc(0), 0, buildDataRequestHeader(device64, assigned16, 0x42)); + await macHandler.processDataReq(Buffer.alloc(0), buildDataRequestHeader(device64, assigned16, 0x41)); + await macHandler.processDataReq(Buffer.alloc(0), buildDataRequestHeader(device64, assigned16, 0x42)); expect(frames.length).toBeGreaterThan(1); let decodedTransport: ReturnType | undefined; diff --git a/test/compliance/mac.test.ts b/test/compliance/mac.test.ts index d16fada..ed16144 100644 --- a/test/compliance/mac.test.ts +++ b/test/compliance/mac.test.ts @@ -3,7 +3,7 @@ * * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: - * - Zigbee specification (05-3474-23): Revision 23.1 + * - Zigbee specification (06-3474-23): Revision 23.1 * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 @@ -163,10 +163,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { mockMACHandlerCallbacks, ); - const beaconFrame = await captureMacFrame( - () => macHandler.processBeaconReq(Buffer.alloc(0), 0, {} as MACHeader), - mockMACHandlerCallbacks, - ); + const beaconFrame = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), {} as MACHeader), mockMACHandlerCallbacks); const dest16 = 0x3344; const dest64 = TEST_DEVICE_EUI64; @@ -275,7 +272,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { mockMACHandlerCallbacks, ); - const beacon = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), 0, {} as MACHeader), mockMACHandlerCallbacks); + const beacon = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), {} as MACHeader), mockMACHandlerCallbacks); expect(command.frameControl.panIdCompression).toStrictEqual(true); expect(beacon.frameControl.panIdCompression).toStrictEqual(false); @@ -294,7 +291,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { mockMACHandlerCallbacks, ); - const beacon = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), 0, {} as MACHeader), mockMACHandlerCallbacks); + const beacon = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), {} as MACHeader), mockMACHandlerCallbacks); expect(shortDest.frameControl.destAddrMode).toStrictEqual(MACFrameAddressMode.SHORT); expect(shortDest.header.destination16).toStrictEqual(0x4abc); @@ -309,7 +306,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { mockMACHandlerCallbacks, ); - const beacon = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), 0, {} as MACHeader), mockMACHandlerCallbacks); + const beacon = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), {} as MACHeader), mockMACHandlerCallbacks); registerNeighborDevice(context, 0x2222, TEST_DEVICE_EUI64); const data = await captureMacFrame( @@ -464,7 +461,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { }); it("omits the destination PAN identifier when the addressing mode is none", async () => { - const decoded = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), 0, {} as MACHeader), mockMACHandlerCallbacks); + const decoded = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), {} as MACHeader), mockMACHandlerCallbacks); expect(decoded.frameControl.destAddrMode).toStrictEqual(MACFrameAddressMode.NONE); expect(decoded.header.destinationPANId).toBeUndefined(); @@ -557,7 +554,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { }); it("omits destination addressing when the addressing mode is none", async () => { - const decoded = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), 0, {} as MACHeader), mockMACHandlerCallbacks); + const decoded = await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), {} as MACHeader), mockMACHandlerCallbacks); expect(decoded.frameControl.destAddrMode).toStrictEqual(MACFrameAddressMode.NONE); expect(decoded.header.destination16).toBeUndefined(); @@ -640,7 +637,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { */ describe("MAC Beacon Frame (IEEE 802.15.4-2020 §6.3.1)", () => { async function generateBeacon(): Promise { - return await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), 0, {} as MACHeader), mockMACHandlerCallbacks); + return await captureMacFrame(() => macHandler.processBeaconReq(Buffer.alloc(0), {} as MACHeader), mockMACHandlerCallbacks); } it("reports superframe defaults for non-beacon networks", async () => { @@ -701,7 +698,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { maxIncomingTransferUnit: ZigbeeMACConsts.FRAME_MAX_SIZE, }, [GlobalTlv.ROUTER_INFORMATION]: { - bitmap: 0b11110101, + bitmask: 0b11110101, }, }); expect(payload.localTlvs.size).toStrictEqual(0); @@ -732,7 +729,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { maxIncomingTransferUnit: ZigbeeMACConsts.FRAME_MAX_SIZE, }, [GlobalTlv.ROUTER_INFORMATION]: { - bitmap: 0b11110111, + bitmask: 0b11110111, }, }); expect(payload.localTlvs.size).toStrictEqual(0); @@ -1043,7 +1040,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { const sendCommandSpy = vi.spyOn(macHandler, "sendCommand").mockResolvedValue(true); currentTime += ZigbeeConsts.MAC_INDIRECT_TRANSMISSION_TIMEOUT - 10; - await macHandler.processDataReq(Buffer.alloc(0), 0, buildDataRequestHeader()); + await macHandler.processDataReq(Buffer.alloc(0), buildDataRequestHeader()); expect(sendCommandSpy).toHaveBeenCalledTimes(1); expect(context.pendingAssociations.has(device64)).toStrictEqual(false); @@ -1064,7 +1061,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { const sendCommandSpy = vi.spyOn(macHandler, "sendCommand"); currentTime += ZigbeeConsts.MAC_INDIRECT_TRANSMISSION_TIMEOUT + 50; - await macHandler.processDataReq(Buffer.alloc(0), 0, buildDataRequestHeader()); + await macHandler.processDataReq(Buffer.alloc(0), buildDataRequestHeader()); expect(sendCommandSpy).not.toHaveBeenCalled(); expect(context.pendingAssociations.has(device64)).toStrictEqual(false); @@ -1234,7 +1231,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { return Promise.resolve(); }); - await macHandler.processDataReq(Buffer.alloc(0), 0, buildDataRequestHeader()); + await macHandler.processDataReq(Buffer.alloc(0), buildDataRequestHeader()); expect(frames).toHaveLength(1); const decoded = decodeMACFramePayload(frames[0]!); @@ -1256,7 +1253,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { const onSendFrame = vi.fn().mockResolvedValue(undefined); mockMACHandlerCallbacks.onSendFrame = onSendFrame; - await macHandler.processDataReq(Buffer.alloc(0), 0, buildDataRequestHeader()); + await macHandler.processDataReq(Buffer.alloc(0), buildDataRequestHeader()); expect(onSendFrame).not.toHaveBeenCalled(); }); @@ -1289,7 +1286,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { return Promise.resolve(); }); - await macHandler.processDataReq(Buffer.alloc(0), 0, buildDataRequestHeader()); + await macHandler.processDataReq(Buffer.alloc(0), buildDataRequestHeader()); expect(frames).toHaveLength(1); const decoded = decodeMACFramePayload(frames[0]!); @@ -1334,7 +1331,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { return Promise.resolve(); }); - await macHandler.processDataReq(Buffer.alloc(0), 0, buildDataRequestHeader(false)); + await macHandler.processDataReq(Buffer.alloc(0), buildDataRequestHeader(false)); expect(expiredTx.sendFrame).not.toHaveBeenCalled(); expect(validTx.sendFrame).toHaveBeenCalledTimes(1); @@ -1459,7 +1456,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { const macHeader = { source64: TEST_DEVICE_EUI64 } as MACHeader; - await macHandler.processDataReq(Buffer.alloc(0), 0, macHeader); + await macHandler.processDataReq(Buffer.alloc(0), macHeader); expect(sendResp).toHaveBeenCalledTimes(1); expect(context.pendingAssociations.has(TEST_DEVICE_EUI64)).toStrictEqual(false); @@ -1483,7 +1480,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { const macHeader = { source64: TEST_DEVICE_EUI64 } as MACHeader; - await macHandler.processDataReq(Buffer.alloc(0), 0, macHeader); + await macHandler.processDataReq(Buffer.alloc(0), macHeader); expect(sendResp).not.toHaveBeenCalled(); expect(context.pendingAssociations.has(TEST_DEVICE_EUI64)).toStrictEqual(false); diff --git a/test/compliance/nwk-gp.test.ts b/test/compliance/nwk-gp.test.ts index d0ac95a..f250e5a 100644 --- a/test/compliance/nwk-gp.test.ts +++ b/test/compliance/nwk-gp.test.ts @@ -3,7 +3,7 @@ * * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: - * - Zigbee specification (05-3474-23): Revision 23.1 + * - Zigbee specification (06-3474-23): Revision 23.1 * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 diff --git a/test/compliance/nwk.test.ts b/test/compliance/nwk.test.ts index 71898f0..c4eba0f 100644 --- a/test/compliance/nwk.test.ts +++ b/test/compliance/nwk.test.ts @@ -3,7 +3,7 @@ * * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: - * - Zigbee specification (05-3474-23): Revision 23.1 + * - Zigbee specification (06-3474-23): Revision 23.1 * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 @@ -53,7 +53,7 @@ import { type StackContextCallbacks, } from "../../src/zigbee-stack/stack-context.js"; import { NETDEF_EXTENDED_PAN_ID, NETDEF_NETWORK_KEY, NETDEF_PAN_ID, NETDEF_TC_KEY } from "../data.js"; -import { createMACFrameControl } from "../utils.js"; +import { createMACFrameControl, defaultDeviceTableEntry } from "../utils.js"; import { captureMacFrame, type DecodedMACFrame, decodeMACFramePayload, NO_ACK_CODE, registerNeighborDevice } from "./utils.js"; describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { @@ -153,7 +153,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { } /** - * Zigbee Spec 05-3474-23 §3.3.1: NWK Frame Format + * Zigbee Spec 06-3474-23 §3.3.1: NWK Frame Format * The NWK frame SHALL consist of a frame control field, addressing fields, * sequence number, radius, and frame payload. */ @@ -220,7 +220,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §3.3.1.8: NWK Sequence Number + * Zigbee Spec 06-3474-23 §3.3.1.8: NWK Sequence Number * The NWK sequence number SHALL be an 8-bit value incremented for each * new transmission and wrapping to 0 after 255. */ @@ -305,7 +305,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §3.5: Network Security + * Zigbee Spec 06-3474-23 §3.5: Network Security * Network layer security SHALL protect NWK frames using network key. */ describe("NWK Security (Zigbee §3.5)", () => { @@ -489,7 +489,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §3.3.1.9: NWK Radius + * Zigbee Spec 06-3474-23 §3.3.1.9: NWK Radius * The radius field SHALL indicate the maximum number of hops a frame will be * relayed. It SHALL be decremented by each relaying device. */ @@ -587,7 +587,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §3.4.1: Route Discovery + * Zigbee Spec 06-3474-23 §3.4.1: Route Discovery * Route discovery SHALL use route request and route reply commands. */ describe("NWK Route Discovery (Zigbee §3.4.1)", () => { @@ -635,7 +635,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §3.4.1.3: Route Maintenance + * Zigbee Spec 06-3474-23 §3.4.1.3: Route Maintenance * Routes SHALL be maintained through route error and route repair mechanisms. */ describe("NWK Route Maintenance (Zigbee §3.4.1.3)", () => { @@ -644,6 +644,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { beforeEach(() => { context.deviceTable.set(failingChild64, { + ...defaultDeviceTableEntry(), address16: failingChild16, capabilities: { alternatePANCoordinator: false, @@ -655,11 +656,6 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(failingChild16, failingChild64); }); @@ -767,6 +763,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { const distant16 = 0x99aa; const distant64 = 0x00124b00aabbccddn; context.deviceTable.set(distant64, { + ...defaultDeviceTableEntry(), address16: distant16, capabilities: { alternatePANCoordinator: false, @@ -777,12 +774,6 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { allocateAddress: true, }, authorized: true, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(distant16, distant64); @@ -804,7 +795,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §3.4.1.6: Many-to-One Routing + * Zigbee Spec 06-3474-23 §3.4.1.6: Many-to-One Routing * Concentrator devices SHALL use many-to-one route requests to establish * routes from all devices back to the concentrator. */ @@ -813,6 +804,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { function registerRouter(neighbor: boolean): void { context.deviceTable.set(routerIeeeAddress, { + ...defaultDeviceTableEntry(), address16: routerShortAddress, capabilities: { alternatePANCoordinator: false, @@ -824,11 +816,6 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }, authorized: true, neighbor, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(routerShortAddress, routerIeeeAddress); } @@ -1072,7 +1059,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §3.4.2: Source Routing + * Zigbee Spec 06-3474-23 §3.4.2: Source Routing * Source routing SHALL allow the originator to specify the relay path. */ describe("NWK Source Routing (Zigbee §3.4.2)", () => { @@ -1246,7 +1233,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §3.6.1: Network Command Frames + * Zigbee Spec 06-3474-23 §3.6.1: Network Command Frames * Network command frames SHALL be used for network management operations. */ describe("NWK Command Frames (Zigbee §3.6.1)", () => { @@ -1552,7 +1539,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §3.6.3: Network Status Command + * Zigbee Spec 06-3474-23 §3.6.3: Network Status Command * Network status command SHALL report errors and conditions using defined codes. */ describe("NWK Network Status Command (Zigbee §3.6.3)", () => { @@ -1739,7 +1726,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §3.6.4: Leave Command + * Zigbee Spec 06-3474-23 §3.6.4: Leave Command * Leave command SHALL allow devices to leave the network gracefully. */ describe("NWK Leave Command (Zigbee §3.6.4)", () => { @@ -1869,7 +1856,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §3.6.5: Rejoin + * Zigbee Spec 06-3474-23 §3.6.5: Rejoin * Rejoin procedures SHALL allow devices to rejoin the network. */ describe("NWK Rejoin Procedure (Zigbee §3.6.5)", () => { @@ -1964,15 +1951,10 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { beforeEach(() => { context.deviceTable.set(rejoiner64, { + ...defaultDeviceTableEntry(), address16: rejoiner16, capabilities: { ...baseCapabilities }, authorized: true, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(rejoiner16, rejoiner64); }); @@ -2005,15 +1987,11 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { it("assigns a new network address and signals conflict when a rejoin collides", async () => { const conflicting64 = 0x00124b00ccddee22n; context.deviceTable.set(conflicting64, { + ...defaultDeviceTableEntry(), address16: rejoiner16, capabilities: { ...baseCapabilities }, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(rejoiner16, conflicting64); const currentDevice = context.deviceTable.get(rejoiner64)!; @@ -2061,7 +2039,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §3.6.6: Link Status + * Zigbee Spec 06-3474-23 §3.6.6: Link Status * Link status command SHALL be used to maintain link cost information. */ describe("NWK Link Status Command (Zigbee §3.6.6)", () => { @@ -2289,7 +2267,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §3.6.8: End Device Timeout + * Zigbee Spec 06-3474-23 §3.6.8: End Device Timeout * End device timeout request/response SHALL manage end device aging. */ describe("NWK End Device Timeout (Zigbee §3.6.8)", () => { @@ -2368,6 +2346,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { beforeEach(() => { context.deviceTable.set(child64, { + ...defaultDeviceTableEntry(), address16: child16, capabilities: { alternatePANCoordinator: false, @@ -2378,12 +2357,6 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { allocateAddress: true, }, authorized: true, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(child16, child64); }); @@ -2424,17 +2397,14 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { expect(context.deviceTable.get(child64)?.endDeviceTimeout).toBeUndefined(); }); - it("signals unsupported feature when the device is unknown", async () => { + it("ignores when the device is unknown", async () => { context.deviceTable.delete(child64); const sendSpy = vi.spyOn(nwkHandler, "sendEdTimeoutResponse"); - const payload = await handleTimeoutRequest(1, { source64: undefined }); - expect(payload).not.toBeUndefined(); - expect(payload!.readUInt8(1)).toStrictEqual(0x02); - expect(sendSpy).toHaveBeenCalled(); - const lastCall = sendSpy.mock.calls.at(-1); - expect(lastCall?.[2]).toStrictEqual(0x02); + const payload = await handleTimeoutRequest(1, { source64: undefined }, false); + expect(payload).toBeUndefined(); + expect(sendSpy).not.toHaveBeenCalled(); }); it("allows overriding status and parent info in outgoing responses", async () => { @@ -2477,7 +2447,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §3.8: Network Constants + * Zigbee Spec 06-3474-23 §3.8: Network Constants * Network layer SHALL enforce specified constants and attributes. */ describe("NWK Constants (Zigbee §3.8)", () => { diff --git a/test/compliance/security.test.ts b/test/compliance/security.test.ts index 9b9b663..ae67f17 100644 --- a/test/compliance/security.test.ts +++ b/test/compliance/security.test.ts @@ -3,7 +3,7 @@ * * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: - * - Zigbee specification (05-3474-23): Revision 23.1 + * - Zigbee specification (06-3474-23): Revision 23.1 * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 @@ -160,7 +160,7 @@ describe("Zigbee 4.0 Security Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §4.3: Security Processing + * Zigbee Spec 06-3474-23 §4.3: Security Processing * Security processing SHALL use CCM* (counter with CBC-MAC) mode. */ type CapturedNwkSecurity = { @@ -387,7 +387,7 @@ describe("Zigbee 4.0 Security Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §4.3.1: Security Levels + * Zigbee Spec 06-3474-23 §4.3.1: Security Levels * Zigbee SHALL use security level 5 (encryption + 32-bit MIC). */ describe("Security Levels (Zigbee §4.3.1)", () => { @@ -419,7 +419,7 @@ describe("Zigbee 4.0 Security Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §4.3.2: Frame Counters + * Zigbee Spec 06-3474-23 §4.3.2: Frame Counters * Frame counters SHALL be maintained per key and SHALL NOT repeat. */ function decodeSecurityFrame(frame: DecodedMACFrame) { @@ -644,7 +644,7 @@ describe("Zigbee 4.0 Security Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §4.5: Trust Center + * Zigbee Spec 06-3474-23 §4.5: Trust Center * Trust Center SHALL manage network security and key distribution. */ describe("Trust Center Operations (Zigbee §4.5)", () => { @@ -695,7 +695,7 @@ describe("Zigbee 4.0 Security Compliance", () => { fcs: 0, }; - await macHandler.processAssocReq(Buffer.from([capabilitiesByte]), 0, macHeader); + await macHandler.processAssocReq(Buffer.from([capabilitiesByte]), macHeader); const pending = context.pendingAssociations.get(device64); expect(pending).not.toBeUndefined(); @@ -910,7 +910,7 @@ describe("Zigbee 4.0 Security Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §4.6.3.2: Well-Known Keys + * Zigbee Spec 06-3474-23 §4.6.3.2: Well-Known Keys * Well-known keys SHALL be used according to Zigbee 4.0 specification. */ describe("Well-Known Keys (Zigbee §4.6.3.2)", () => { @@ -939,7 +939,7 @@ describe("Zigbee 4.0 Security Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §4.6.3.4: Install Codes + * Zigbee Spec 06-3474-23 §4.6.3.4: Install Codes * Install codes SHALL be used to derive preconfigured link keys. */ describe("Install Codes (Zigbee §4.6.3.4)", () => { @@ -1049,7 +1049,7 @@ describe("Zigbee 4.0 Security Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §4.6.3.5: Network Key Update + * Zigbee Spec 06-3474-23 §4.6.3.5: Network Key Update * Network key update SHALL allow periodic key rotation. */ describe("Network Key Update (Zigbee §4.6.3.5)", () => { @@ -1119,7 +1119,7 @@ describe("Zigbee 4.0 Security Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §4.6.3.6: Trust Center Link Key Update + * Zigbee Spec 06-3474-23 §4.6.3.6: Trust Center Link Key Update * TC link key update SHALL use APS request/verify/confirm key commands. */ describe("Trust Center Link Key Update (Zigbee §4.6.3.6)", () => { @@ -1304,7 +1304,7 @@ describe("Zigbee 4.0 Security Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §4.6.3.7: Application Link Keys + * Zigbee Spec 06-3474-23 §4.6.3.7: Application Link Keys * Application link keys SHALL be established between communicating devices. */ describe("Application Link Keys (Zigbee §4.6.3.7)", () => { @@ -1496,7 +1496,7 @@ describe("Zigbee 4.0 Security Compliance", () => { }); /** - * Zigbee Spec 05-3474-23 §4.7: Key Storage + * Zigbee Spec 06-3474-23 §4.7: Key Storage * Devices SHALL securely store cryptographic keys. */ describe("Key Storage (Zigbee §4.7)", () => { diff --git a/test/compliance/utils.ts b/test/compliance/utils.ts index 1691493..23dfa83 100644 --- a/test/compliance/utils.ts +++ b/test/compliance/utils.ts @@ -3,7 +3,7 @@ * * These tests verify that the handlers adhere to the Zigbee specification. * Tests are derived from: - * - Zigbee specification (05-3474-23): Revision 23.1 + * - Zigbee specification (06-3474-23): Revision 23.1 * - Base device behavior (16-02828-012): v3.1 * - ZCL specification (07-5123): Revision 8 * - Green Power specification (14-0563-19): Version 1.1.2 @@ -30,6 +30,7 @@ import { } from "../../src/zigbee/zigbee-nwk.js"; import type { MACHandlerCallbacks } from "../../src/zigbee-stack/mac-handler.js"; import type { NetworkParameters, StackContext } from "../../src/zigbee-stack/stack-context.js"; +import { defaultDeviceTableEntry } from "../utils.js"; export const NO_ACK_CODE = 99999; @@ -111,6 +112,7 @@ export async function captureMacFrame(action: () => Promise | unknown, export function registerNeighborDevice(context: StackContext, address16: number, address64: bigint): void { context.deviceTable.set(address64, { + ...defaultDeviceTableEntry(), address16, capabilities: { alternatePANCoordinator: false, @@ -122,26 +124,17 @@ export function registerNeighborDevice(context: StackContext, address16: number, }, authorized: true, neighbor: true, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - lastTransportedNetworkKeySeq: undefined, - linkStatusMisses: undefined, }); context.address16ToAddress64.set(address16, address64); } export function registerDevice(context: StackContext, address16: number, address64: bigint, neighbor: boolean, capabilities?: MACCapabilities): void { context.deviceTable.set(address64, { + ...defaultDeviceTableEntry(), address16, capabilities, authorized: true, neighbor, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - lastTransportedNetworkKeySeq: undefined, - linkStatusMisses: undefined, }); context.address16ToAddress64.set(address16, address64); } diff --git a/test/drivers/ot-rcp-driver.test.ts b/test/drivers/ot-rcp-driver.test.ts index 0821d25..06ed846 100644 --- a/test/drivers/ot-rcp-driver.test.ts +++ b/test/drivers/ot-rcp-driver.test.ts @@ -102,6 +102,7 @@ import { NETDEF_ZGP_COMMISSIONING, NETDEF_ZGP_FRAME_BCAST_RECALL_SCENE_0, } from "../data.js"; +import { defaultDeviceTableEntry } from "../utils.js"; const randomBigInt = (): bigint => BigInt(`0x${randomBytes(8).toString("hex")}`); @@ -542,48 +543,32 @@ describe("OT RCP Driver", () => { ]); driver.context.netParams.tcKeyFrameCounter = 896723; driver.context.deviceTable.set(1234n, { + ...defaultDeviceTableEntry(), address16: 1, capabilities: structuredClone(COMMON_FFD_MAC_CAP), authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); driver.context.deviceTable.set(12656887476334n, { + ...defaultDeviceTableEntry(), address16: 3457, capabilities: structuredClone(COMMON_FFD_MAC_CAP), authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); driver.context.deviceTable.set(12328965645634n, { + ...defaultDeviceTableEntry(), address16: 9674, capabilities: structuredClone(COMMON_RFD_MAC_CAP), authorized: true, neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); driver.context.deviceTable.set(234367481234n, { + ...defaultDeviceTableEntry(), address16: 54748, capabilities: structuredClone(COMMON_FFD_MAC_CAP), authorized: false, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); driver.context.sourceRouteTable.set(1, [ createTestSourceRouteEntry([], 1, sourceRouteLastUpdated1One), @@ -630,48 +615,32 @@ describe("OT RCP Driver", () => { expect(driver.context.netParams.tcKeyFrameCounter).toStrictEqual(896723 + 1024); expect(driver.context.deviceTable.size).toStrictEqual(4); expect(driver.context.deviceTable.get(1234n)).toStrictEqual({ + ...defaultDeviceTableEntry(), address16: 1, capabilities: structuredClone(COMMON_FFD_MAC_CAP), authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); expect(driver.context.deviceTable.get(12656887476334n)).toStrictEqual({ + ...defaultDeviceTableEntry(), address16: 3457, capabilities: structuredClone(COMMON_FFD_MAC_CAP), authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); expect(driver.context.deviceTable.get(12328965645634n)).toStrictEqual({ + ...defaultDeviceTableEntry(), address16: 9674, capabilities: structuredClone(COMMON_RFD_MAC_CAP), authorized: true, neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); expect(driver.context.deviceTable.get(234367481234n)).toStrictEqual({ + ...defaultDeviceTableEntry(), address16: 54748, capabilities: structuredClone(COMMON_FFD_MAC_CAP), authorized: false, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); expect(driver.context.address16ToAddress64.size).toStrictEqual(4); expect(driver.context.address16ToAddress64.get(1)).toStrictEqual(1234n); @@ -2013,15 +1982,11 @@ describe("OT RCP Driver", () => { // encrypted at NWK+APS const source64 = BigInt("0xa4c1386d9b280fdf"); driver.context.deviceTable.set(source64, { + ...defaultDeviceTableEntry(), address16: 0xa18f, capabilities: structuredClone(COMMON_FFD_MAC_CAP), authorized: false, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); driver.context.address16ToAddress64.set(0xa18f, source64); @@ -2129,15 +2094,12 @@ describe("OT RCP Driver", () => { expect(sendAssocRspSpy).toHaveBeenCalledWith(11871832136131022815n, 0xa18f, MACAssociationStatus.SUCCESS); expect(sendTransportKeyNWKSpy).toHaveBeenCalledTimes(1); expect(driver.context.deviceTable.get(11871832136131022815n)).toStrictEqual({ + ...defaultDeviceTableEntry(), address16: 0xa18f, capabilities: structuredClone(COMMON_FFD_MAC_CAP), authorized: false, neighbor: true, lastTransportedNetworkKeySeq: 0, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); driver.parser._transform(makeSpinelStreamRaw(1, NET2_DEVICE_ANNOUNCE_BCAST, Buffer.from([0xd8, 0xff, 0x00, 0x00])), "utf8", () => {}); @@ -2172,15 +2134,15 @@ describe("OT RCP Driver", () => { await vi.advanceTimersByTimeAsync(10); expect(driver.context.deviceTable.get(11871832136131022815n)).toStrictEqual({ + ...defaultDeviceTableEntry(), address16: 0xa18f, capabilities: structuredClone(COMMON_FFD_MAC_CAP), authorized: true, neighbor: true, lastTransportedNetworkKeySeq: 0, recentLQAs: [200, 153, 178, 188], + lastReceivedRssi: -43, incomingNWKFrameCounter: 33498, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); }); @@ -2239,15 +2201,11 @@ describe("OT RCP Driver", () => { // joined devices // 5c:c7:c1:ff:fe:5e:70:ea driver.context.deviceTable.set(6685525477083214058n, { + ...defaultDeviceTableEntry(), address16: 0x3ab1, capabilities: structuredClone(COMMON_FFD_MAC_CAP), authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); driver.context.address16ToAddress64.set(0x3ab1, 6685525477083214058n); // not set on purpose to observe change from actual route record @@ -3086,85 +3044,61 @@ describe("OT RCP Driver", () => { // joined devices // 80:4b:50:ff:fe:a4:b9:73 driver.context.deviceTable.set(9244571720527165811n, { + ...defaultDeviceTableEntry(), address16: 0x96ba, capabilities: structuredClone(COMMON_FFD_MAC_CAP), authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); driver.context.address16ToAddress64.set(0x96ba, 9244571720527165811n); // driver.context.sourceRouteTable.set(0x96ba, [{relayAddresses: [], pathCost: 1}]); // 70:ac:08:ff:fe:d0:4a:58 driver.context.deviceTable.set(8118874123826907736n, { + ...defaultDeviceTableEntry(), address16: 0x91d2, capabilities: structuredClone(COMMON_FFD_MAC_CAP), authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); driver.context.address16ToAddress64.set(0x91d2, 8118874123826907736n); // driver.context.sourceRouteTable.set(0x91d2, [{relayAddresses: [], pathCost: 1}]); // 00:12:4b:00:24:c2:e1:e1 driver.context.deviceTable.set(5149013569626593n, { + ...defaultDeviceTableEntry(), address16: 0xcb47, capabilities: structuredClone(COMMON_FFD_MAC_CAP), authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); driver.context.address16ToAddress64.set(0xcb47, 5149013569626593n); // mimic no source route entry for 0xcb47 // 00:12:4b:00:29:27:fd:8c driver.context.deviceTable.set(5149013643361676n, { + ...defaultDeviceTableEntry(), address16: 0x6887, capabilities: structuredClone(COMMON_RFD_MAC_CAP), authorized: true, neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); driver.context.address16ToAddress64.set(0x6887, 5149013643361676n); // driver.context.sourceRouteTable.set(0x6887, [{relayAddresses: [0x96ba], pathCost: 2}]); // 00:12:4b:00:25:49:f4:42 driver.context.deviceTable.set(5149013578478658n, { + ...defaultDeviceTableEntry(), address16: 0x9ed5, capabilities: structuredClone(COMMON_RFD_MAC_CAP), authorized: true, neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); driver.context.address16ToAddress64.set(0x9ed5, 5149013578478658n); // driver.context.sourceRouteTable.set(0x9ed5, [{relayAddresses: [0x91d2], pathCost: 2}]); // 00:12:4b:00:25:02:d0:3b driver.context.deviceTable.set(5149013573816379n, { + ...defaultDeviceTableEntry(), address16: 0x4b8e, capabilities: structuredClone(COMMON_RFD_MAC_CAP), authorized: true, neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); driver.context.address16ToAddress64.set(0x4b8e, 5149013573816379n); // driver.context.sourceRouteTable.set(0x4b8e, [{relayAddresses: [0xcb47], pathCost: 2}]); diff --git a/test/utils.ts b/test/utils.ts index f596a22..3b8faf5 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,5 +1,21 @@ import { MACFrameAddressMode, type MACFrameControl, MACFrameType, MACFrameVersion, type MACHeader } from "../src/zigbee/mac.js"; import type { ZigbeeNWKGPHeader } from "../src/zigbee/zigbee-nwkgp.js"; +import type { DeviceTableEntry } from "../src/zigbee-stack/stack-context.js"; + +export function defaultDeviceTableEntry(): DeviceTableEntry { + return { + address16: 0x0001, + capabilities: undefined, + authorized: false, + neighbor: false, + lastTransportedNetworkKeySeq: undefined, + recentLQAs: [], + lastReceivedRssi: undefined, + incomingNWKFrameCounter: undefined, + endDeviceTimeout: undefined, + linkStatusMisses: 0, + }; +} /** Helper to create minimal MAC frame control */ export function createMACFrameControl( diff --git a/test/zigbee-stack/aps-handler.test.ts b/test/zigbee-stack/aps-handler.test.ts index 84e26f0..fdfd286 100644 --- a/test/zigbee-stack/aps-handler.test.ts +++ b/test/zigbee-stack/aps-handler.test.ts @@ -24,7 +24,7 @@ import { StackContext, type StackContextCallbacks, } from "../../src/zigbee-stack/stack-context.js"; -import { createMACHeader } from "../utils.js"; +import { createMACHeader, defaultDeviceTableEntry } from "../utils.js"; describe("APS Handler", () => { let saveDir: string; @@ -182,15 +182,11 @@ describe("APS Handler", () => { // Add device to device table mockContext.deviceTable.set(destination64, { + ...defaultDeviceTableEntry(), address16: destination16, capabilities: undefined, authorized: false, neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(destination16, destination64); @@ -244,15 +240,8 @@ describe("APS Handler", () => { // Add device to device table mockContext.deviceTable.set(destination64, { + ...defaultDeviceTableEntry(), address16: destination16, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(destination16, destination64); @@ -271,15 +260,8 @@ describe("APS Handler", () => { // Add device to device table mockContext.deviceTable.set(destination64, { + ...defaultDeviceTableEntry(), address16: destination16, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(destination16, destination64); @@ -298,15 +280,8 @@ describe("APS Handler", () => { // Add device to device table mockContext.deviceTable.set(destination64, { + ...defaultDeviceTableEntry(), address16: destination16, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(destination16, destination64); @@ -325,15 +300,8 @@ describe("APS Handler", () => { // Add device to device table mockContext.deviceTable.set(destination64, { + ...defaultDeviceTableEntry(), address16: destination16, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(destination16, destination64); @@ -352,15 +320,8 @@ describe("APS Handler", () => { // Add device to device table mockContext.deviceTable.set(destination64, { + ...defaultDeviceTableEntry(), address16: destination16, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(destination16, destination64); @@ -378,15 +339,8 @@ describe("APS Handler", () => { // Add device to device table mockContext.deviceTable.set(destination64, { + ...defaultDeviceTableEntry(), address16: destination16, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(destination16, destination64); @@ -405,15 +359,8 @@ describe("APS Handler", () => { // Add device to device table mockContext.deviceTable.set(destination64, { + ...defaultDeviceTableEntry(), address16: destination16, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(destination16, destination64); @@ -608,15 +555,11 @@ describe("APS Handler", () => { const device64 = 0x00124b0000004422n; mockContext.deviceTable.set(device64, { + ...defaultDeviceTableEntry(), address16: device16, capabilities: undefined, authorized: true, neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); const payload = Buffer.alloc(12); @@ -666,15 +609,8 @@ describe("APS Handler", () => { const device64 = 0x00124b0000005522n; mockContext.deviceTable.set(device64, { + ...defaultDeviceTableEntry(), address16: device16, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); const payload = Buffer.alloc(12); @@ -740,6 +676,7 @@ describe("APS Handler", () => { it("should generate LQI table response", () => { // Add some neighbor devices to device table mockContext.deviceTable.set(0x00124b0011111111n, { + ...defaultDeviceTableEntry(), address16: 0x1234, capabilities: { alternatePANCoordinator: false, @@ -751,14 +688,11 @@ describe("APS Handler", () => { }, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, recentLQAs: [150, 155, 152], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.deviceTable.set(0x00124b0022222222n, { + ...defaultDeviceTableEntry(), address16: 0x5678, capabilities: { alternatePANCoordinator: false, @@ -770,23 +704,15 @@ describe("APS Handler", () => { }, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, recentLQAs: [100], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.deviceTable.set(0x00124b0033333333n, { + ...defaultDeviceTableEntry(), address16: 0x9abc, capabilities: undefined, authorized: false, neighbor: false, // Not a neighbor, should be skipped - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); const lqiTable = apsHandler.getLQITableResponse(0); @@ -823,6 +749,7 @@ describe("APS Handler", () => { // Add multiple neighbor devices for (let i = 0; i < 10; i++) { mockContext.deviceTable.set(BigInt(0x00124b0000000000 + i), { + ...defaultDeviceTableEntry(), address16: 0x1000 + i, capabilities: { alternatePANCoordinator: false, @@ -834,11 +761,6 @@ describe("APS Handler", () => { }, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); } @@ -1491,15 +1413,8 @@ describe("APS Handler", () => { it("resolves IEEE destination when sending data", async () => { const destination64 = 0x00124b0099999999n; mockContext.deviceTable.set(destination64, { + ...defaultDeviceTableEntry(), address16: 0x7788, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(0x7788, destination64); @@ -1607,15 +1522,11 @@ describe("APS Handler", () => { const responder64 = 0x00124b0044332211n; mockContext.address16ToAddress64.set(0x3344, responder64); mockContext.deviceTable.set(responder64, { + ...defaultDeviceTableEntry(), address16: 0x3344, capabilities: undefined, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); const apsHeader = { frameControl: { @@ -1652,15 +1563,11 @@ describe("APS Handler", () => { mockContext.address16ToAddress64.set(requester16, requester64); mockContext.address16ToAddress64.set(partner16, partner64); mockContext.deviceTable.set(partner64, { + ...defaultDeviceTableEntry(), address16: partner16, capabilities: undefined, authorized: true, neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.setAppLinkKey(requester64, partner64, cachedKey); @@ -1814,15 +1721,11 @@ describe("APS Handler", () => { const dest64 = 0x00124b0000007001n; mockContext.deviceTable.set(dest64, { + ...defaultDeviceTableEntry(), address16: dest16, capabilities: undefined, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(dest16, dest64); @@ -1870,15 +1773,11 @@ describe("APS Handler", () => { const dest64 = 0x00124b0000007002n; mockContext.deviceTable.set(dest64, { + ...defaultDeviceTableEntry(), address16: dest16, capabilities: undefined, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(dest16, dest64); @@ -1941,6 +1840,7 @@ describe("APS Handler", () => { neighbor: true, lastTransportedNetworkKeySeq: undefined, recentLQAs: [], + lastReceivedRssi: undefined, incomingNWKFrameCounter: undefined, endDeviceTimeout: undefined, linkStatusMisses: 0, @@ -2038,15 +1938,11 @@ describe("APS Handler", () => { const dest64 = 0x00124b0000007004n; mockContext.deviceTable.set(dest64, { + ...defaultDeviceTableEntry(), address16: dest16, capabilities: undefined, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(dest16, dest64); @@ -2106,15 +2002,11 @@ describe("APS Handler", () => { const clearTimeoutSpy = vi.spyOn(global, "clearTimeout").mockImplementation(() => {}); mockContext.deviceTable.set(dest64, { + ...defaultDeviceTableEntry(), address16: dest16, capabilities: undefined, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(dest16, dest64); @@ -2215,15 +2107,11 @@ describe("APS Handler", () => { for (let index = 0; index < 260; index += 1) { const address64 = 0x00124b0000100000n + BigInt(index); mockContext.deviceTable.set(address64, { + ...defaultDeviceTableEntry(), address16: 0x1000 + index, capabilities: undefined, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); } diff --git a/test/zigbee-stack/mac-handler.test.ts b/test/zigbee-stack/mac-handler.test.ts index 169768d..d536f96 100644 --- a/test/zigbee-stack/mac-handler.test.ts +++ b/test/zigbee-stack/mac-handler.test.ts @@ -28,7 +28,7 @@ import { ZigbeeConsts } from "../../src/zigbee/zigbee.js"; import { ZigbeeNWKConsts } from "../../src/zigbee/zigbee-nwk.js"; import { MACHandler, type MACHandlerCallbacks } from "../../src/zigbee-stack/mac-handler.js"; import { type NetworkParameters, StackContext, type StackContextCallbacks } from "../../src/zigbee-stack/stack-context.js"; -import { createMACFrameControl } from "../utils.js"; +import { createMACFrameControl, defaultDeviceTableEntry } from "../utils.js"; const NO_ACK_CODE = 99999; @@ -143,15 +143,8 @@ describe("MACHandler", () => { const dest16 = 0x5678; mockContext.deviceTable.set(dest64, { + ...defaultDeviceTableEntry(), address16: dest16, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(dest16, dest64); @@ -411,7 +404,7 @@ describe("MACHandler", () => { }; const data = Buffer.from([0x8e]); // capabilities: rxOnWhenIdle=true, deviceType=FFD, powerSource=mains, securityCapability=true, allocateAddress=true - await macHandler.processAssocReq(data, 0, macHeader); + await macHandler.processAssocReq(data, macHeader); expect(mockContext.associate).toHaveBeenCalledWith(undefined, 0x00124b0098765432n, true, expect.any(Object), true, false); expect(mockContext.pendingAssociations.has(0x00124b0098765432n)).toStrictEqual(true); @@ -424,15 +417,11 @@ describe("MACHandler", () => { const dest16 = 0x5678; mockContext.deviceTable.set(dest64, { + ...defaultDeviceTableEntry(), address16: dest16, capabilities: undefined, authorized: true, neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); const macHeader: MACHeader = { @@ -446,7 +435,7 @@ describe("MACHandler", () => { }; const data = Buffer.from([0x8e]); - await macHandler.processAssocReq(data, 0, macHeader); + await macHandler.processAssocReq(data, macHeader); expect(mockContext.associate).toHaveBeenCalledWith(dest16, dest64, false, expect.any(Object), true, false); }); @@ -463,7 +452,7 @@ describe("MACHandler", () => { }; const data = Buffer.from([0x8e]); - await macHandler.processAssocReq(data, 0, macHeader); + await macHandler.processAssocReq(data, macHeader); expect(mockContext.associate).not.toHaveBeenCalled(); }); @@ -496,7 +485,7 @@ describe("MACHandler", () => { fcs: 0, }; - await macHandler.processBeaconReq(Buffer.alloc(0), 0, macHeader); + await macHandler.processBeaconReq(Buffer.alloc(0), macHeader); expect(mockCallbacks.onSendFrame).toHaveBeenCalledOnce(); @@ -533,7 +522,7 @@ describe("MACHandler", () => { fcs: 0, }; - await macHandler.processBeaconReq(Buffer.alloc(0), 0, macHeader); + await macHandler.processBeaconReq(Buffer.alloc(0), macHeader); expect(mockCallbacks.onSendFrame).toHaveBeenCalledOnce(); @@ -569,7 +558,7 @@ describe("MACHandler", () => { fcs: 0, }; - await macHandler.processDataReq(Buffer.alloc(0), 0, macHeader); + await macHandler.processDataReq(Buffer.alloc(0), macHeader); expect(sendResp).toHaveBeenCalledOnce(); expect(mockContext.pendingAssociations.has(dest64)).toStrictEqual(false); @@ -594,7 +583,7 @@ describe("MACHandler", () => { fcs: 0, }; - await macHandler.processDataReq(Buffer.alloc(0), 0, macHeader); + await macHandler.processDataReq(Buffer.alloc(0), macHeader); expect(sendResp).not.toHaveBeenCalled(); expect(mockContext.pendingAssociations.has(dest64)).toStrictEqual(false); @@ -621,7 +610,7 @@ describe("MACHandler", () => { fcs: 0, }; - await macHandler.processDataReq(Buffer.alloc(0), 0, macHeader); + await macHandler.processDataReq(Buffer.alloc(0), macHeader); expect(sendFrame).toHaveBeenCalledOnce(); expect(mockContext.indirectTransmissions.get(dest64)?.length).toStrictEqual(0); @@ -653,7 +642,7 @@ describe("MACHandler", () => { fcs: 0, }; - await macHandler.processDataReq(Buffer.alloc(0), 0, macHeader); + await macHandler.processDataReq(Buffer.alloc(0), macHeader); expect(expiredSendFrame).not.toHaveBeenCalled(); expect(validSendFrame).toHaveBeenCalledOnce(); @@ -684,7 +673,7 @@ describe("MACHandler", () => { fcs: 0, }; - await macHandler.processDataReq(Buffer.alloc(0), 0, macHeader); + await macHandler.processDataReq(Buffer.alloc(0), macHeader); expect(sendFrame).toHaveBeenCalledOnce(); }); @@ -732,7 +721,7 @@ describe("MACHandler", () => { it("encodes beacon responses with Zigbee beacon payload", async () => { getOnSendFrameMock().mockClear(); - await macHandler.processBeaconReq(Buffer.alloc(0), 0, { + await macHandler.processBeaconReq(Buffer.alloc(0), { frameControl: createMACFrameControl(MACFrameType.CMD, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), sequenceNumber: 0, destinationPANId: 0xffff, @@ -1104,7 +1093,7 @@ describe("MACHandler", () => { maxIncomingTransferUnit: ZigbeeMACConsts.FRAME_MAX_SIZE, }, [GlobalTlv.ROUTER_INFORMATION]: { - bitmap: 0b11110101, + bitmask: 0b11110101, }, }); expect(decoded.localTlvs.size).toStrictEqual(0); diff --git a/test/zigbee-stack/nwk-handler.test.ts b/test/zigbee-stack/nwk-handler.test.ts index f12d36f..c881889 100644 --- a/test/zigbee-stack/nwk-handler.test.ts +++ b/test/zigbee-stack/nwk-handler.test.ts @@ -9,6 +9,7 @@ import { ZigbeeNWKCommandId, ZigbeeNWKConsts, type ZigbeeNWKHeader } from "../.. import { MACHandler, type MACHandlerCallbacks } from "../../src/zigbee-stack/mac-handler.js"; import { NWKHandler, type NWKHandlerCallbacks } from "../../src/zigbee-stack/nwk-handler.js"; import { type NetworkParameters, StackContext, type StackContextCallbacks } from "../../src/zigbee-stack/stack-context.js"; +import { defaultDeviceTableEntry } from "../utils.js"; describe("NWK Handler", () => { let saveDir: string; @@ -584,7 +585,7 @@ describe("NWK Handler", () => { associateSpy.mockClear(); sendFrameSpy.mockClear(); - const offset = await nwkHandler.processRejoinReq( + await nwkHandler.processRejoinReq( Buffer.from([0x8e]), 0, { @@ -611,7 +612,6 @@ describe("NWK Handler", () => { } as ZigbeeNWKHeader, ); - expect(offset).toStrictEqual(1); expect(associateSpy).not.toHaveBeenCalled(); expect(sendFrameSpy).not.toHaveBeenCalled(); }); @@ -658,6 +658,7 @@ describe("NWK Handler", () => { const device64 = 0x00124b0012345678n; mockContext.deviceTable.set(device64, { + ...defaultDeviceTableEntry(), address16: device16, authorized: true, capabilities: { @@ -669,11 +670,7 @@ describe("NWK Handler", () => { allocateAddress: true, }, neighbor: false, - lastTransportedNetworkKeySeq: undefined, recentLQAs: [255], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); const macHeader: MACHeader = { @@ -738,6 +735,7 @@ describe("NWK Handler", () => { mockContext.address16ToAddress64.set(device16, device64); mockContext.deviceTable.set(device64, { + ...defaultDeviceTableEntry(), address16: device16, authorized: true, capabilities: { @@ -749,11 +747,6 @@ describe("NWK Handler", () => { allocateAddress: true, }, neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); const existing = nwkHandler.createSourceRouteEntry([], 5); @@ -822,7 +815,7 @@ describe("NWK Handler", () => { 0x05, // path cost = 5 ]); - const offset = nwkHandler.processRouteReply( + nwkHandler.processRouteReply( payload, 0, { @@ -839,8 +832,6 @@ describe("NWK Handler", () => { seqNum: 20, } as ZigbeeNWKHeader, ); - - expect(offset).toBeGreaterThan(0); }); it("refreshes existing source route entries when coordinator receives reply", () => { @@ -898,7 +889,7 @@ describe("NWK Handler", () => { const device16 = 0x1234; const payload = Buffer.from([ZigbeeNWKCommandId.NWK_STATUS, 0x0b, 0x56, 0x34]); - const offset = await nwkHandler.processStatus( + await nwkHandler.processStatus( payload, 0, { @@ -913,8 +904,6 @@ describe("NWK Handler", () => { seqNum: 20, } as ZigbeeNWKHeader, ); - - expect(offset).toBeGreaterThan(0); }); it("should send network status", async () => { @@ -933,6 +922,7 @@ describe("NWK Handler", () => { mockContext.address16ToAddress64.set(device16, device64); mockContext.deviceTable.set(device64, { + ...defaultDeviceTableEntry(), address16: device16, capabilities: { rxOnWhenIdle: false, @@ -944,16 +934,11 @@ describe("NWK Handler", () => { }, authorized: true, neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); const payload = Buffer.from([ZigbeeNWKCommandId.ED_TIMEOUT_REQUEST, 0x04, 0x00]); - const offset = await nwkHandler.processEdTimeoutRequest( + await nwkHandler.processEdTimeoutRequest( payload, 0, { @@ -971,7 +956,6 @@ describe("NWK Handler", () => { } as ZigbeeNWKHeader, ); - expect(offset).toStrictEqual(2); // Command ID + requested timeout = 2 (config mask is not read) expect(mockMACHandler.sendFrame).toHaveBeenCalled(); }); @@ -985,15 +969,32 @@ describe("NWK Handler", () => { expect(mockMACHandler.sendFrame).toHaveBeenCalled(); }); - it("reports unsupported end device timeout when IEEE mapping missing", async () => { + it("reports incorrect value for end device timeout", async () => { + const device16 = 0x1234; + const device64 = 0x00124b0012345678n; + mockContext.address16ToAddress64.set(device16, device64); + mockContext.deviceTable.set(device64, { + ...defaultDeviceTableEntry(), + address16: device16, + authorized: true, + capabilities: { + alternatePANCoordinator: false, + deviceType: 0, + powerSource: 0, + rxOnWhenIdle: false, + securityCapability: false, + allocateAddress: false, + }, + }); + const sendSpy = vi.spyOn(nwkHandler, "sendEdTimeoutResponse").mockResolvedValue(true); await nwkHandler.processEdTimeoutRequest( - Buffer.from([0x01, 0x00]), + Buffer.from([0xff, 0x00]), 0, { frameControl: {}, - source16: 0x4004, + source16: device16, sequenceNumber: 9, } as MACHeader, { @@ -1009,14 +1010,14 @@ describe("NWK Handler", () => { endDeviceInitiator: false, }, destination16: ZigbeeConsts.COORDINATOR_ADDRESS, - source16: 0x4004, + source16: device16, source64: undefined, radius: 1, seqNum: 19, } as ZigbeeNWKHeader, ); - expect(sendSpy).toHaveBeenCalledWith(0x4004, 0x01, 0x02); + expect(sendSpy).toHaveBeenCalledWith(device16, 0xff, 0x01); sendSpy.mockRestore(); }); @@ -1036,7 +1037,7 @@ describe("NWK Handler", () => { // No PAN IDs since count = 0 ]); - const offset = nwkHandler.processReport( + nwkHandler.processReport( payload, 0, { @@ -1051,11 +1052,24 @@ describe("NWK Handler", () => { seqNum: 20, } as ZigbeeNWKHeader, ); + }); + + it("should send update pan id", async () => { + const device16 = 0x1234; + mockContext.address16ToAddress64.set(device16, 0x00124b0012345678n); + let nextPanId = mockContext.netParams.panId + 1; - expect(offset).toStrictEqual(9); // options (1) + extended PAN ID (8) = 9 + if (nextPanId > 0xfffe) { + nextPanId = 0x0001; + } + + const result = await nwkHandler.sendUpdatePanId(mockContext.netParams.extendedPanId, mockContext.netParams.nwkUpdateId + 1, nextPanId); + + expect(result).toStrictEqual(true); + expect(mockMACHandler.sendFrame).toHaveBeenCalled(); }); - it("should process link power delta", () => { + it("should process link power delta", async () => { const device16 = 0x1234; const payload = Buffer.from([ 0x01, // options: type = 1 (request) @@ -1065,7 +1079,7 @@ describe("NWK Handler", () => { 0x05, ]); - const offset = nwkHandler.processLinkPwrDelta( + await nwkHandler.processLinkPwrDelta( payload, 0, { @@ -1080,15 +1094,13 @@ describe("NWK Handler", () => { seqNum: 20, } as ZigbeeNWKHeader, ); - - expect(offset).toStrictEqual(5); }); it("should process commissioning request", async () => { const device16 = 0x1234; - const payload = Buffer.from([ZigbeeNWKCommandId.COMMISSIONING_REQUEST, 0x62, 0x1a, 15]); + const payload = Buffer.from([0x00, 0x8e]); - const offset = await nwkHandler.processCommissioningRequest( + await nwkHandler.processCommissioningRequest( payload, 0, { @@ -1103,8 +1115,6 @@ describe("NWK Handler", () => { seqNum: 20, } as ZigbeeNWKHeader, ); - - expect(offset).toStrictEqual(2); // Command ID + PAN ID (2 bytes) = 3, but channel not read }); it("should send commissioning response", async () => { @@ -1122,27 +1132,21 @@ describe("NWK Handler", () => { const device2Addr64 = 0x00124b0087654321n; mockContext.deviceTable.set(device1Addr64, { + ...defaultDeviceTableEntry(), address16: 0x1234, capabilities: { rxOnWhenIdle: true, deviceType: 1, alternatePANCoordinator: false } as MACCapabilities, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, recentLQAs: [200], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.deviceTable.set(device2Addr64, { + ...defaultDeviceTableEntry(), address16: 0x5678, capabilities: { rxOnWhenIdle: true, deviceType: 1, alternatePANCoordinator: false } as MACCapabilities, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, recentLQAs: [180], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(0x1234, device1Addr64); @@ -1156,15 +1160,11 @@ describe("NWK Handler", () => { it("zeroes link costs after router age limit misses", async () => { const deviceAddr64 = 0x00124b0012345612n; mockContext.deviceTable.set(deviceAddr64, { + ...defaultDeviceTableEntry(), address16: 0x1357, capabilities: { rxOnWhenIdle: true, deviceType: 1, alternatePANCoordinator: false } as MACCapabilities, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(0x1357, deviceAddr64); @@ -1340,15 +1340,11 @@ describe("NWK Handler", () => { it("stores route record using IEEE source when short address missing", async () => { const device64 = 0x00124b0000667788n; mockContext.deviceTable.set(device64, { + ...defaultDeviceTableEntry(), address16: 0x7788, capabilities: undefined, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); const existingEntry = nwkHandler.createSourceRouteEntry([0x1001], 2); @@ -1382,15 +1378,11 @@ describe("NWK Handler", () => { const responder64 = 0x00124b0010102020n; mockContext.deviceTable.set(responder64, { + ...defaultDeviceTableEntry(), address16: responder16, capabilities: undefined, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); mockContext.address16ToAddress64.set(responder16, responder64); @@ -1433,15 +1425,11 @@ describe("NWK Handler", () => { it("skips neighbors without short address mapping in periodic link status", async () => { const device64 = 0x00124b00aa55eeffn; mockContext.deviceTable.set(device64, { + ...defaultDeviceTableEntry(), address16: 0x8899, capabilities: undefined, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); const linkSpy = vi.spyOn(nwkHandler, "sendLinkStatus").mockResolvedValue(); @@ -1451,4 +1439,80 @@ describe("NWK Handler", () => { expect(linkSpy).toHaveBeenCalledWith([]); linkSpy.mockRestore(); }); + + it("fragments link status command when payload too large", async () => { + const sendSpy = vi.spyOn(nwkHandler, "sendCommand").mockResolvedValue(true); + const maxLinksPayloadSize = ZigbeeNWKConsts.PAYLOAD_MIN_SIZE - 2; + const maxLinksPerFrame = (maxLinksPayloadSize / 3) | 0; + const links = Array.from({ length: maxLinksPerFrame * 2 + 2 }, (_, index) => ({ + address: 0x1000 + index, + incomingCost: index % 2 ? 1 : 2, + outgoingCost: index % 2 ? 2 : 4, + })); + + const buildLinkStatusPayload = (startIndex: number, count: number, isFirst: boolean, isLast: boolean) => { + const options = isFirst ? 32 + count : isLast ? 64 + count : count; + const header = Buffer.from([ZigbeeNWKCommandId.LINK_STATUS, options]); + const entries = links.slice(startIndex, startIndex + count).map((_v, i) => { + const buffer = Buffer.allocUnsafe(3); + buffer.writeUInt16LE(0x1000 + i + startIndex, 0); + buffer.writeUInt8((i + startIndex) % 2 ? 33 : 66, 2); + + return buffer; + }); + + return Buffer.concat([header, ...entries]); + }; + + await nwkHandler.sendLinkStatus(links); + + expect(sendSpy).toHaveBeenCalledTimes(3); + + const firstPayload = sendSpy.mock.calls[0]?.[1] as Buffer; + const secondPayload = sendSpy.mock.calls[1]?.[1] as Buffer; + const thirdPayload = sendSpy.mock.calls[2]?.[1] as Buffer; + + expect(firstPayload).toStrictEqual(buildLinkStatusPayload(0, maxLinksPerFrame, true, false)); + expect(secondPayload).toStrictEqual(buildLinkStatusPayload(maxLinksPerFrame - 1, maxLinksPerFrame, false, false)); + expect(thirdPayload).toStrictEqual(buildLinkStatusPayload(maxLinksPerFrame * 2 - 2, 2 + 2, false, true)); + + sendSpy.mockRestore(); + }); + + it("fragments link power delta command when payload too large", async () => { + const sendSpy = vi.spyOn(nwkHandler, "sendCommand").mockResolvedValue(true); + const maxDeltasPayloadSize = ZigbeeNWKConsts.PAYLOAD_MIN_SIZE - 3; + const maxDeltasPerFrame = (maxDeltasPayloadSize / 3) | 0; + const deltas = Array.from({ length: maxDeltasPerFrame * 2 + 1 }, (_, index) => ({ + device: 0x2000 + index, + delta: -3, + })); + + const buildLinkPwrDeltaPayload = (startIndex: number, count: number) => { + const header = Buffer.from([ZigbeeNWKCommandId.LINK_PWR_DELTA, ZigbeeNWKConsts.CMD_NWK_LINK_PWR_DELTA_TYPE_NOTIFICATION, count]); + const entries = deltas.slice(startIndex, startIndex + count).map((_v, i) => { + const buffer = Buffer.allocUnsafe(3); + buffer.writeUInt16LE(0x2000 + i + startIndex, 0); + buffer.writeInt8(-3, 2); + + return buffer; + }); + + return Buffer.concat([header, ...entries]); + }; + + await nwkHandler.sendLinkPwrDelta(ZigbeeConsts.BCAST_RX_ON_WHEN_IDLE, ZigbeeNWKConsts.CMD_NWK_LINK_PWR_DELTA_TYPE_NOTIFICATION, deltas); + + expect(sendSpy).toHaveBeenCalledTimes(3); + + const firstPayload = sendSpy.mock.calls[0]?.[1] as Buffer; + const secondPayload = sendSpy.mock.calls[1]?.[1] as Buffer; + const thirdPayload = sendSpy.mock.calls[2]?.[1] as Buffer; + + expect(firstPayload).toStrictEqual(buildLinkPwrDeltaPayload(0, maxDeltasPerFrame)); + expect(secondPayload).toStrictEqual(buildLinkPwrDeltaPayload(maxDeltasPerFrame, maxDeltasPerFrame)); + expect(thirdPayload).toStrictEqual(buildLinkPwrDeltaPayload(maxDeltasPerFrame * 2, 1)); + + sendSpy.mockRestore(); + }); }); diff --git a/test/zigbee-stack/stack-context.test.ts b/test/zigbee-stack/stack-context.test.ts index 630cf92..28555ac 100644 --- a/test/zigbee-stack/stack-context.test.ts +++ b/test/zigbee-stack/stack-context.test.ts @@ -14,6 +14,7 @@ import { type StackContextCallbacks, TrustCenterKeyRequestPolicy, } from "../../src/zigbee-stack/stack-context.js"; +import { defaultDeviceTableEntry } from "../utils.js"; const createNetParams = (): NetworkParameters => ({ eui64: 0x00124b0012345678n, @@ -420,15 +421,11 @@ describe("StackContext", () => { context.netParams.tcKeyFrameCounter = 84; context.deviceTable.set(device64, { + ...defaultDeviceTableEntry(), address16, capabilities, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(address16, device64); @@ -521,15 +518,11 @@ describe("StackContext", () => { const longRelays = Array.from({ length: 70 }, (_, index) => 0x4000 + index); context.deviceTable.set(device64, { + ...defaultDeviceTableEntry(), address16, capabilities, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(address16, device64); context.sourceRouteTable.set(address16, [ @@ -611,15 +604,9 @@ describe("StackContext", () => { const address16 = 0x3456; context.deviceTable.set(device64, { + ...defaultDeviceTableEntry(), address16, capabilities, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(address16, device64); @@ -687,15 +674,11 @@ describe("StackContext", () => { }; context.deviceTable.set(device64, { + ...defaultDeviceTableEntry(), address16, capabilities, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, }); context.address16ToAddress64.set(address16, device64); context.sourceRouteTable.set(address16, [sourceRoute]); diff --git a/test/zigbee-stack/zigbee-stack.bench.ts b/test/zigbee-stack/zigbee-stack.bench.ts index 3fd647a..c8f6625 100644 --- a/test/zigbee-stack/zigbee-stack.bench.ts +++ b/test/zigbee-stack/zigbee-stack.bench.ts @@ -23,6 +23,7 @@ import { NETDEF_ZGP_COMMISSIONING, NETDEF_ZGP_FRAME_BCAST_RECALL_SCENE_0, } from "../data.js"; +import { defaultDeviceTableEntry } from "../utils.js"; const NO_ACK_CODE = 99999; @@ -261,15 +262,10 @@ describe("Zigbee Stack Handlers", () => { const source64 = macHeader.source64!; const source16 = macHeader.source16!; context.deviceTable.set(source64, { + ...defaultDeviceTableEntry(), address16: source16, - capabilities: undefined, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: undefined, }); context.address16ToAddress64.set(source16, source64); @@ -330,15 +326,11 @@ describe("Zigbee Stack Handlers", () => { for (const neighbor of neighbors) { context.deviceTable.set(neighbor.address64, { + ...defaultDeviceTableEntry(), address16: neighbor.address16, - capabilities: undefined, authorized: true, neighbor: true, - lastTransportedNetworkKeySeq: undefined, recentLQAs: [255, 240, 230], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: undefined, }); context.address16ToAddress64.set(neighbor.address16, neighbor.address64); } @@ -404,15 +396,10 @@ describe("Zigbee Stack Handlers", () => { // Setup device and routing context.deviceTable.set(dest64, { + ...defaultDeviceTableEntry(), address16: dest16, capabilities: undefined, authorized: true, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: undefined, }); context.address16ToAddress64.set(dest16, dest64); @@ -510,15 +497,8 @@ describe("Zigbee Stack Handlers", () => { const source64 = macHeader.source64!; const source16 = nwkHeader.source16!; context.deviceTable.set(source64, { + ...defaultDeviceTableEntry(), address16: source16, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: undefined, }); context.address16ToAddress64.set(source16, source64); @@ -548,15 +528,8 @@ describe("Zigbee Stack Handlers", () => { const dest16 = 0x1234; context.deviceTable.set(dest64, { + ...defaultDeviceTableEntry(), address16: dest16, - capabilities: undefined, - authorized: false, - neighbor: false, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: undefined, }); context.address16ToAddress64.set(dest16, dest64); diff --git a/test/zigbee/tlvs.test.ts b/test/zigbee/tlvs.test.ts index b743d37..0dca143 100644 --- a/test/zigbee/tlvs.test.ts +++ b/test/zigbee/tlvs.test.ts @@ -59,7 +59,7 @@ describe("Zigbee TLVs", () => { expect(globalTlvs[GlobalTlv.NEXT_PAN_ID]).toStrictEqual({ panId: 0x1122 }); expect(globalTlvs[GlobalTlv.NEXT_CHANNEL_CHANGE]).toStrictEqual({ channel: 0x01020304 }); expect(globalTlvs[GlobalTlv.SYMMETRIC_PASSPHRASE]?.passphrase).toStrictEqual(Buffer.alloc(ZigbeeConsts.SEC_KEYSIZE, 0xab)); - expect(globalTlvs[GlobalTlv.ROUTER_INFORMATION]).toStrictEqual({ bitmap: 0x3344 }); + expect(globalTlvs[GlobalTlv.ROUTER_INFORMATION]).toStrictEqual({ bitmask: 0x3344 }); expect(globalTlvs[GlobalTlv.FRAGMENTATION_PARAMETERS]).toStrictEqual({ nwkAddress: 0x5678, fragmentationOptions: 0x9a, From 108901c952fbf7d45cf7bf96d80dcfb02d0e4dde Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:47:24 +0100 Subject: [PATCH 6/9] fix: refactor various association methods --- src/zigbee-stack/aps-handler.ts | 47 +--- src/zigbee-stack/mac-handler.ts | 58 +++-- src/zigbee-stack/nwk-handler.ts | 208 +++++++++-------- src/zigbee-stack/stack-context.ts | 249 +++++--------------- src/zigbee/zigbee-aps.ts | 8 - test/compliance/aps.test.ts | 9 +- test/compliance/bdb.test.ts | 39 +--- test/compliance/integration.test.ts | 35 --- test/compliance/mac.test.ts | 27 +-- test/compliance/nwk.test.ts | 31 --- test/compliance/security.test.ts | 48 ---- test/drivers/ot-rcp-driver.test.ts | 314 +------------------------- test/zigbee-stack/aps-handler.test.ts | 10 +- test/zigbee-stack/mac-handler.test.ts | 31 +-- test/zigbee-stack/nwk-handler.test.ts | 22 +- 15 files changed, 237 insertions(+), 899 deletions(-) diff --git a/src/zigbee-stack/aps-handler.ts b/src/zigbee-stack/aps-handler.ts index 5e92220..5996db1 100644 --- a/src/zigbee-stack/aps-handler.ts +++ b/src/zigbee-stack/aps-handler.ts @@ -1611,28 +1611,10 @@ export class APSHandler { ); if (status === ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_SECURED_REJOIN) { - await this.#context.associate( - device16, - device64, - false, // rejoin - undefined, // no MAC cap through router - false, // not neighbor - false, - true, // was allowed by parent - ); - + await this.#context.associate(device16, device64, undefined, false, false); this.#updateSourceRouteForChild(device16, nwkHeader.source16, nwkHeader.source64); } else if (status === ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_UNSECURED_JOIN) { - await this.#context.associate( - device16, - device64, - true, // initial join - undefined, // no MAC cap through router - false, // not neighbor - false, - true, // was allowed by parent - ); - + await this.#context.associate(device16, device64, undefined, false, true); this.#updateSourceRouteForChild(device16, nwkHeader.source16, nwkHeader.source64); const tApsCmdPayload = Buffer.allocUnsafe(19 + ZigbeeAPSConsts.CMD_KEY_LENGTH); @@ -1672,31 +1654,12 @@ export class APSHandler { ); await this.sendTunnel(nwkHeader.source16!, device64, tApsCmdFrame); - this.#context.markNetworkKeyTransported(device64); } else if (status === ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_TRUST_CENTER_REJOIN) { - // rejoin - const [, , requiresTransportKey] = await this.#context.associate( - device16, - device64, - false, // rejoin - undefined, // no MAC cap through router - false, // not neighbor - false, - true, // was allowed by parent, expected valid - ); - - if (requiresTransportKey) { - await this.sendTransportKeyNWK( - device16, - this.#context.netParams.networkKey, - this.#context.netParams.networkKeySequenceNumber, - device64, - ); - this.#context.markNetworkKeyTransported(device64); - } + await this.#context.associate(device16, device64, undefined, false, false); + await this.sendTransportKeyNWK(device16, this.#context.netParams.networkKey, this.#context.netParams.networkKeySequenceNumber, device64); + this.#context.markNetworkKeyTransported(device64); } else if (status === ZigbeeAPSUpdateDeviceStatus.DEVICE_LEFT) { - // left // TODO: according to spec: // A Device Left is considered informative but SHOULD NOT be considered authoritative. // Security related actions SHALL not be taken on receipt of this. No further processing SHALL be done. diff --git a/src/zigbee-stack/mac-handler.ts b/src/zigbee-stack/mac-handler.ts index cf6f7e5..9a9c408 100644 --- a/src/zigbee-stack/mac-handler.ts +++ b/src/zigbee-stack/mac-handler.ts @@ -291,49 +291,47 @@ export class MACHandler { /** * Process 802.15.4 MAC association request. * - * SPEC COMPLIANCE NOTES (IEEE 802.15.4-2015 #6.3.1): - * - ✅ Correctly extracts capabilities byte from payload - * - ✅ Validates presence of source64 (mandatory per spec) - * - ✅ Enforces associationPermit flag for initial joins (PAN access denied when false) - * - ✅ Calls context associate to handle higher-layer processing - * - ✅ Determines initial join vs rejoin by checking if device is known - * - ✅ Stores pending association in map for DATA_REQ retrieval - * - ✅ Pending association includes sendResp callback and timestamp - * - ✅ SPEC COMPLIANCE: Association response is indirect transmission - * - Per IEEE 802.15.4 #6.3.2, response SHALL be sent via indirect transmission - * - Implementation stores in pendingAssociations for DATA_REQ ✅ - * - Respects macResponseWaitTime via timestamp check ✅ - * - ✅ Delivers TRANSPORT_KEY_NWK after successful association (Zigbee Trust Center requirement) - * - ✅ Uses MAC capabilities to determine device type correctly + * SPEC COMPLIANCE: + * - TODO + * * DEVICE SCOPE: Coordinator, routers (N/A) */ public async processAssocReq(data: Buffer, macHeader: MACHeader): Promise { if (macHeader.source64 === undefined) { - logger.debug(() => `<=x= MAC ASSOC_REQ[macSrc=${macHeader.source16}:${macHeader.source64}] Invalid source64`, NS); - return; + return; // invalid } const capabilities = data.readUInt8(0); + const decodedCap = decodeMACCapabilities(capabilities); logger.debug(() => `<=== MAC ASSOC_REQ[macSrc=${macHeader.source16}:${macHeader.source64} cap=${capabilities}]`, NS); - const device = this.#context.deviceTable.get(macHeader.source64); - const address16 = device?.address16; - const decodedCap = decodeMACCapabilities(capabilities); - const [status, newAddress16, requiresTransportKey] = await this.#context.associate( - address16, - macHeader.source64, - !device?.authorized /* rejoin only if was previously authorized */, - decodedCap, - true /* neighbor */, - address16 === undefined && !this.#context.associationPermit, - ); + if (this.#context.deviceTable.has(macHeader.source64)) { + return; // XXX: duplicate, spec? + } + + let newAddress16 = 0xffff; + let status = MACAssociationStatus.PAN_ACCESS_DENIED; + let requiresTransportKey = false; + + if (this.#context.macAssociationPermit && this.#context.trustCenterPolicies.allowJoins) { + newAddress16 = this.#context.assignNetworkAddress(); + + if (newAddress16 === 0xffff) { + status = MACAssociationStatus.PAN_FULL; + } else { + status = MACAssociationStatus.SUCCESS; + requiresTransportKey = true; + + await this.#context.associate(newAddress16, macHeader.source64, decodedCap, true, true); + } + } this.#context.pendingAssociations.set(macHeader.source64, { sendResp: async () => { await this.sendAssocRsp(macHeader.source64!, newAddress16, status); - if (status === MACAssociationStatus.SUCCESS && requiresTransportKey) { + if (requiresTransportKey) { await this.#callbacks.onAPSSendTransportKeyNWK( newAddress16, this.#context.netParams.networkKey, @@ -418,7 +416,7 @@ export class MACHandler { * * Value 0x0f means no GTS, which is typical for Zigbee ✅ * - ✅ Sets batteryExtension=false (coordinator is mains powered) * - ✅ Sets panCoordinator=true (this is the coordinator) - * - ✅ Uses associationPermit flag from context + * - ✅ Uses macAssociationPermit flag from context * - ✅ Sets gtsInfo.permit=false (no GTS support - typical for Zigbee) * - ✅ Empty pendAddr (no pending address fields) * - ✅ Zigbee Beacon Payload: @@ -461,7 +459,7 @@ export class MACHandler { finalCAPSlot: 0x0f, batteryExtension: false, panCoordinator: true, - associationPermit: this.#context.associationPermit, + associationPermit: this.#context.macAssociationPermit, }, gtsInfo: { permit: false }, pendAddr: {}, diff --git a/src/zigbee-stack/nwk-handler.ts b/src/zigbee-stack/nwk-handler.ts index a97e4c8..6ea3fac 100644 --- a/src/zigbee-stack/nwk-handler.ts +++ b/src/zigbee-stack/nwk-handler.ts @@ -1251,27 +1251,9 @@ export class NWKHandler { * 06-3474-23 #3.4.6 * Optional: Child Rejoining the Network to a Legacy Parent (Pre-R23) * - * SPEC COMPLIANCE NOTES: - * - ✅ Correctly decodes capabilities byte - * - ✅ Determines rejoin type based on frameControl.security: - * - security=false: Trust Center Rejoin (unsecured) - * - security=true: NWK rejoin (secured with NWK key) - * - ✅ TRUST CENTER REJOIN HANDLING: - * - Checks if device is known and authorized ✅ - * - Denies rejoin if device unknown or unauthorized ✅ - * - ✅ Centralized Trust Center enforces coordinator EUI64; distributed/uninitialized modes not supported here (N/A) - * - ✅ Calls context associate with correct parameters: - * - initialJoin=false (this is a rejoin) ✅ - * - neighbor determined by comparing MAC and NWK source ✅ - * - denyOverride based on security analysis ✅ - * - ✅ Sends REJOIN_RESP with assigned address and status - * - ✅ Re-distributes current NWK key when rejoin requires key update - * - ✅ Does not require VERIFY_KEY after rejoin per spec note + * SPEC COMPLIANCE: + * - TODO * - * SECURITY CONCERNS: - * - Unsecured rejoin handling is critical for security - * - Must validate device authorization before accepting - * - Missing apsTrustCenterAddress validation is a security gap * DEVICE SCOPE: Coordinator, routers (N/A) * * @param data Command data @@ -1281,70 +1263,70 @@ export class NWKHandler { * @returns New offset after processing */ public async processRejoinReq(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): Promise { + if (nwkHeader.source64 === undefined || nwkHeader.source16 === undefined) { + return; // invalid + } + + const secured = nwkHeader.frameControl.security; const capabilities = data.readUInt8(offset); const decodedCap = decodeMACCapabilities(capabilities); logger.debug( () => - `<=== NWK REJOIN_REQ[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} cap=${capabilities}]`, + `<=== NWK REJOIN_REQ[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} sec=${secured} cap=${capabilities}]`, NS, ); - let deny = false; - let source64 = nwkHeader.source64; + let newAddress16 = 0xffff; + let status: MACAssociationStatus | number = MACAssociationStatus.PAN_ACCESS_DENIED; + let requiresTransportKey = false; + const device = this.#context.deviceTable.get(nwkHeader.source64); - if (!nwkHeader.frameControl.security) { - // Trust Center Rejoin - if (source64 === undefined) { - if (nwkHeader.source16 === undefined) { - // invalid, drop completely, should never happen - return; - } + // TODO: + // The relationship field of the new neighbor table entry SHALL be set to the value 0x01 only if the mechanism was NWK Rejoin and had NWK Layer security. + // Otherwise, the relationship field SHALL be set to 0x05 indicating an unauthenticated child. - source64 = this.#context.address16ToAddress64.get(nwkHeader.source16); - } + // NOTE: a device does not have to verify its trust center link key with the APSME-VERIFY-KEY services after a rejoin. - if (source64 === undefined) { - // can't identify device - deny = true; - } else { - const device = this.#context.deviceTable.get(source64); + // Trust Center Rejoin OR Secured Rejoin (same logic, only key transport changes) + // XXX: Unsecured Packets at the network layer claiming to be from existing neighbors (coordinators, routers or end devices) must not rewrite legitimate data in the nwkNeighborTable. + // if apsTrustCenterAddress is all FF (distributed) / all 00 (pre-TRANSPORT_KEY), reject with PAN_ACCESS_DENIED + if (device?.authorized) { + // device changed its address and it's conflicting, assign new one + if (device.address16 !== nwkHeader.source16 && this.#context.address16ToAddress64.has(nwkHeader.source16)) { + newAddress16 = this.#context.assignNetworkAddress(); + + if (newAddress16 === 0xffff) { + status = MACAssociationStatus.PAN_FULL; + } else { + status = ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT; + requiresTransportKey = !secured; + const neighbor = macHeader.source16 === nwkHeader.source16; - // XXX: Unsecured Packets at the network layer claiming to be from existing neighbors (coordinators, routers or end devices) must not rewrite legitimate data in the nwkNeighborTable. - // if apsTrustCenterAddress is all FF (distributed) / all 00 (pre-TRANSPORT_KEY), reject with PAN_ACCESS_DENIED - if (!device?.authorized) { - // device unknown or unauthorized - deny = true; + await this.#context.associate(newAddress16, nwkHeader.source64, decodedCap, neighbor, false); } + } else { + newAddress16 = nwkHeader.source16; + status = MACAssociationStatus.SUCCESS; + requiresTransportKey = !secured; + const neighbor = macHeader.source16 === nwkHeader.source16; + + await this.#context.associate(newAddress16, nwkHeader.source64, decodedCap, neighbor, false); } } - const [status, newAddress16, requiresTransportKey] = await this.#context.associate( - nwkHeader.source16!, - source64, - false /* rejoin */, - decodedCap, - macHeader.source16 === nwkHeader.source16, - deny, - ); - // TODO: - // The relationship field of the new neighbor table entry SHALL be set to the value 0x01 only if the mechanism was NWK Rejoin and had NWK Layer security. - // Otherwise, the relationship field SHALL be set to 0x05 indicating an unauthenticated child. + await this.sendRejoinResp(nwkHeader.source16, newAddress16, status); - await this.sendRejoinResp(nwkHeader.source16!, newAddress16, status); - - // XXX: is this spec? - if (status === MACAssociationStatus.SUCCESS && requiresTransportKey && source64 !== undefined) { + if (requiresTransportKey) { + // XXX: is this spec? await this.#callbacks.onAPSSendTransportKeyNWK( newAddress16, this.#context.netParams.networkKey, this.#context.netParams.networkKeySequenceNumber, - source64, + nwkHeader.source64, ); - this.#context.markNetworkKeyTransported(source64); + this.#context.markNetworkKeyTransported(nwkHeader.source64); } - - // NOTE: a device does not have to verify its trust center link key with the APSME-VERIFY-KEY services after a rejoin. } // NOTE: sendRejoinReq DEVICE SCOPE: routers (N/A), end devices (N/A) @@ -1616,7 +1598,7 @@ export class NWKHandler { * Per spec, after sending this, should start a timer of `CONFIG_NWK_BCAST_DELIVERY_TIME` then apply the changes internally. * * SPEC COMPLIANCE NOTES: - * - TODO + * - TODO * * DEVICE SCOPE: Coordinator, routers (N/A) */ @@ -1820,7 +1802,7 @@ export class NWKHandler { * and only unicast when type is `CMD_NWK_LINK_PWR_DELTA_TYPE_RESPONSE`. * * SPEC COMPLIANCE: - * - TODO + * - TODO * * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) */ @@ -1876,24 +1858,9 @@ export class NWKHandler { * 06-3474-23 #3.4.14 * Optional: R23+ * - * SPEC COMPLIANCE NOTES: - * - ✅ Correctly decodes assocType and capabilities - * - ✅ Determines initial join vs rejoin from assocType: - * - 0x00 = Initial Join ✅ - * - 0x01 = Rejoin ✅ - * - ✅ Determines neighbor by comparing MAC and NWK source addresses - * - ✅ Calls context associate with appropriate parameters - * - ✅ Sends COMMISSIONING_RESPONSE with status and address - * - ✅ Sends TRANSPORT_KEY_NWK on SUCCESS when required - * - ✅ Commissioning TLVs (R23+) - * - ⚠️ SPEC NOTE: Comment about sending Remove Device CMD to deny join - * - Alternative to normal rejection mechanism - * - Not implemented here + * SPEC COMPLIANCE: + * - TODO * - * COMMISSIONING vs NORMAL JOIN: - * - Commissioning is R23+ feature for network commissioning - * - May have different security requirements than legacy join - * - TLV support is critical for full R23 compliance * DEVICE SCOPE: Coordinator * * @param data Command data @@ -1903,14 +1870,17 @@ export class NWKHandler { * @returns New offset after processing */ public async processCommissioningRequest(data: Buffer, offset: number, macHeader: MACHeader, nwkHeader: ZigbeeNWKHeader): Promise { + if (nwkHeader.source64 === undefined || nwkHeader.source16 === undefined) { + return; // invalid + } + // ZigbeeNWKCommissioningType const commissioningType = data.readUInt8(offset); offset += 1; - const initialJoin = commissioningType === ZigbeeNWKCommissioningType.INITIAL_JOIN; + const secured = nwkHeader.frameControl.security; - if (nwkHeader.frameControl.security && initialJoin) { - // per spec, drop - return; + if (secured && commissioningType === ZigbeeNWKCommissioningType.INITIAL_JOIN) { + return; // per spec, drop } const capabilities = data.readUInt8(offset); @@ -1944,24 +1914,72 @@ export class NWKHandler { logger.debug( () => - `<=== NWK COMMISSIONING_REQUEST[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} type=${commissioningType} cap=${capabilities}]`, + `<=== NWK COMMISSIONING_REQUEST[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} sec=${secured} type=${commissioningType} cap=${capabilities}]`, NS, ); // NOTE: send Remove Device CMD to TC deny the join (or let timeout): `sendRemoveDevice` - const [status, newAddress16, requiresTransportKey] = await this.#context.associate( - nwkHeader.source16!, - nwkHeader.source64, - initialJoin, - decodedCap, - macHeader.source16 === nwkHeader.source16, - nwkHeader.frameControl.security /* deny if true */, - ); + let newAddress16 = 0xffff; + let status: MACAssociationStatus | number = MACAssociationStatus.PAN_ACCESS_DENIED; + let requiresTransportKey = false; + + if (commissioningType === ZigbeeNWKCommissioningType.INITIAL_JOIN) { + if (this.#context.trustCenterPolicies.allowJoins) { + if (this.#context.address16ToAddress64.has(nwkHeader.source16)) { + // device address is conflicting, assign new one + newAddress16 = this.#context.assignNetworkAddress(); + + if (newAddress16 === 0xffff) { + status = MACAssociationStatus.PAN_FULL; + } else { + status = ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT; + requiresTransportKey = true; + const neighbor = macHeader.source16 === nwkHeader.source16; + + await this.#context.associate(newAddress16, nwkHeader.source64, decodedCap, neighbor, true); + } + } else { + newAddress16 = nwkHeader.source16; + status = MACAssociationStatus.SUCCESS; + requiresTransportKey = true; + const neighbor = macHeader.source16 === nwkHeader.source16; + + await this.#context.associate(newAddress16, nwkHeader.source64, decodedCap, neighbor, true); + } + } + } else if (commissioningType === ZigbeeNWKCommissioningType.REJOIN) { + const device = this.#context.deviceTable.get(nwkHeader.source64); + + if (device?.authorized) { + // Secured Rejoin OR Trust Center Rejoin (same logic, only key transport changes) + if (device.address16 !== nwkHeader.source16 && this.#context.address16ToAddress64.has(nwkHeader.source16)) { + // device changed its address and it's conflicting, assign new one + newAddress16 = this.#context.assignNetworkAddress(); + + if (newAddress16 === 0xffff) { + status = MACAssociationStatus.PAN_FULL; + } else { + status = ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT; + requiresTransportKey = !secured; + const neighbor = macHeader.source16 === nwkHeader.source16; + + await this.#context.associate(newAddress16, nwkHeader.source64, decodedCap, neighbor, false); + } + } else { + newAddress16 = nwkHeader.source16; + status = MACAssociationStatus.SUCCESS; + requiresTransportKey = !secured; + const neighbor = macHeader.source16 === nwkHeader.source16; + + await this.#context.associate(newAddress16, nwkHeader.source64, decodedCap, neighbor, false); + } + } + } - await this.sendCommissioningResponse(nwkHeader.source16!, newAddress16, status); + await this.sendCommissioningResponse(nwkHeader.source16, newAddress16, status); - if (status === MACAssociationStatus.SUCCESS) { + if (requiresTransportKey) { // TODO: might need to be different if R23 or not if (selectedKeyNegotiationMethod === GlobalTlvConsts.KEY_NEGOTATION_METHOD_STATIC) { const dest64 = this.#context.address16ToAddress64.get(newAddress16); diff --git a/src/zigbee-stack/stack-context.ts b/src/zigbee-stack/stack-context.ts index 3a354ba..a552cbf 100644 --- a/src/zigbee-stack/stack-context.ts +++ b/src/zigbee-stack/stack-context.ts @@ -1,6 +1,6 @@ import { readFile, rm, writeFile } from "node:fs/promises"; import { logger } from "../utils/logger.js"; -import { decodeMACCapabilities, encodeMACCapabilities, MACAssociationStatus, type MACCapabilities, type MACHeader } from "../zigbee/mac.js"; +import { decodeMACCapabilities, encodeMACCapabilities, type MACCapabilities, type MACHeader } from "../zigbee/mac.js"; import { aes128MmoHash, computeInstallCodeCRC, @@ -12,7 +12,6 @@ import { ZigbeeKeyType, } from "../zigbee/zigbee.js"; import type { ZigbeeAPSHeader, ZigbeeAPSPayload } from "../zigbee/zigbee-aps.js"; -import { ZigbeeNWKConsts } from "../zigbee/zigbee-nwk.js"; import type { ZigbeeNWKGPHeader } from "../zigbee/zigbee-nwkgp.js"; import { encodeCoordinatorDescriptors } from "./descriptors.js"; import { CONFIG_NWK_MAX_HOPS } from "./nwk-handler.js"; @@ -422,7 +421,7 @@ export class StackContext { /** Pre-computed hash of default TC link key for VERIFY_KEY */ tcVerifyKeyHash: Buffer = Buffer.alloc(0); /** MAC association permit flag */ - associationPermit = false; + macAssociationPermit = false; //---- Trust Center (see 06-3474-23 #4.7.1) @@ -1275,7 +1274,7 @@ export class StackContext { * - ✅ Clears timer correctly * - ✅ Updates Trust Center allowJoins policy * - ✅ Maintains allowRejoinsWithWellKnownKey for rejoins - * - ✅ Sets associationPermit flag for MAC layer + * - ✅ Sets macAssociationPermit flag for MAC layer * DEVICE SCOPE: Trust Center */ public disallowJoins(): void { @@ -1284,7 +1283,7 @@ export class StackContext { this.trustCenterPolicies.allowJoins = false; this.trustCenterPolicies.allowRejoinsWithWellKnownKey = true; - this.associationPermit = false; + this.macAssociationPermit = false; logger.info("Disallowed joins", NS); } @@ -1293,7 +1292,7 @@ export class StackContext { * SPEC COMPLIANCE: * - ✅ Implements timed join window per spec * - ✅ Updates Trust Center policies - * - ✅ Sets MAC associationPermit flag + * - ✅ Sets MAC macAssociationPermit flag * - ✅ Clamps 0xff to 0xfe for security * - ✅ Auto-disallows after timeout * DEVICE SCOPE: Trust Center @@ -1309,7 +1308,7 @@ export class StackContext { this.trustCenterPolicies.allowJoins = true; this.trustCenterPolicies.allowRejoinsWithWellKnownKey = true; - this.associationPermit = macAssociationPermit; + this.macAssociationPermit = macAssociationPermit; this.#allowJoinTimeout = setTimeout(this.disallowJoins.bind(this), Math.min(duration, 0xfe) * 1000); @@ -1320,209 +1319,65 @@ export class StackContext { } /** - * Handle device association (initial join or rejoin) + * Handle device association (join) * * 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 + * - TODO + * * 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 + * @returns true if transporting key is required (up to the caller to handle) */ public async associate( - source16: number | undefined, - source64: bigint | undefined, - initialJoin: boolean, + source16: number, + source64: bigint, capabilities: MACCapabilities | undefined, neighbor: boolean, - denyOverride?: boolean, - allowOverride?: boolean, - ): Promise<[status: MACAssociationStatus | number, newAddress16: number, requiresTransportKey: boolean]> { - // 0xffff when not successful and should not be retried - let newAddress16 = source16; - let status: MACAssociationStatus | number = MACAssociationStatus.SUCCESS; - let unknownRejoin = false; - let requiresTransportKey = false; - - if (denyOverride) { - newAddress16 = 0xffff; - status = 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 === ZigbeeConsts.COORDINATOR_ADDRESS || source16 >= ZigbeeConsts.BCAST_MIN) { - // MAC join (no `source16`) - newAddress16 = this.assignNetworkAddress(); - - if (newAddress16 === 0xffff) { - status = 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 = 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 = MACAssociationStatus.PAN_FULL; - } else { - // tell device to use the newly generated value - status = ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT; - } - } - } - } - } else { - newAddress16 = 0xffff; - status = MACAssociationStatus.PAN_ACCESS_DENIED; - } - } else { - // rejoin - if (source16 === undefined || source16 === ZigbeeConsts.COORDINATOR_ADDRESS || source16 >= ZigbeeConsts.BCAST_MIN) { - // rejoin without 16, generate one (XXX: never happens?) - newAddress16 = this.assignNetworkAddress(); - - if (newAddress16 === 0xffff) { - status = 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 = MACAssociationStatus.PAN_FULL; - } else { - // tell device to use the newly generated value - status = 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 = 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 === MACAssociationStatus.SUCCESS && - initialJoin && - this.trustCenterPolicies.installCode === InstallCodePolicy.REQUIRED && - (source64 === undefined || this.installCodeTable.get(source64) === undefined) - ) { - newAddress16 = 0xffff; - status = MACAssociationStatus.PAN_ACCESS_DENIED; - } - - logger.debug( - () => - `DEVICE_JOINING[src=${source16}:${source64} newAddr16=${newAddress16} initialJoin=${initialJoin} deviceType=${capabilities?.deviceType} powerSource=${capabilities?.powerSource} rxOnWhenIdle=${capabilities?.rxOnWhenIdle}] replying with status=${status}`, - NS, - ); - - if (status === MACAssociationStatus.SUCCESS) { - if (initialJoin || unknownRejoin) { - this.deviceTable.set(source64!, { - address16: newAddress16, - capabilities, // TODO: only valid if not triggered by `processUpdateDevice` - // on initial join success, device is considered joined but unauthorized after MAC Assoc / NWK Commissioning response is sent - authorized: false, - neighbor, - lastTransportedNetworkKeySeq: undefined, - recentLQAs: [], - lastReceivedRssi: undefined, - incomingNWKFrameCounter: undefined, - endDeviceTimeout: undefined, - linkStatusMisses: 0, - }); - this.address16ToAddress64.set(newAddress16, source64!); + initialJoin: boolean, + ): Promise { + if (initialJoin) { + logger.debug( + () => + `DEVICE_JOINING[src=${source16}:${source64} deviceType=${capabilities?.deviceType} powerSource=${capabilities?.powerSource} rxOnWhenIdle=${capabilities?.rxOnWhenIdle}]`, + NS, + ); - // `processUpdateDevice` has no `capabilities` info, device is joined through router, so, no indirect tx for coordinator - if (capabilities && !capabilities.rxOnWhenIdle) { - this.indirectTransmissions.set(source64!, []); - } + this.deviceTable.set(source64, { + address16: source16, + capabilities, + // on initial join success, device is considered joined but unauthorized after MAC Assoc / NWK Commissioning response is sent + authorized: false, + neighbor, + lastTransportedNetworkKeySeq: undefined, + recentLQAs: [], + lastReceivedRssi: undefined, + incomingNWKFrameCounter: undefined, + endDeviceTimeout: undefined, + linkStatusMisses: 0, + }); + this.address16ToAddress64.set(source16, source64); - requiresTransportKey = true; - } else { - // update records on rejoin in case anything has changed (like neighbor for routing) - this.address16ToAddress64.set(newAddress16, source64!); - const device = this.deviceTable.get(source64!)!; - device.address16 = newAddress16; - device.capabilities = capabilities; - device.neighbor = neighbor; - - if (device.lastTransportedNetworkKeySeq !== this.netParams.networkKeySequenceNumber) { - requiresTransportKey = true; - } + // `processUpdateDevice` has no `capabilities` info, device is joined through router, so, no indirect tx for coordinator + if (capabilities && !capabilities.rxOnWhenIdle) { + this.indirectTransmissions.set(source64, []); } + } else { + logger.debug( + () => + `DEVICE_REJOINING[src=${source16}:${source64} deviceType=${capabilities?.deviceType} powerSource=${capabilities?.powerSource} rxOnWhenIdle=${capabilities?.rxOnWhenIdle}]`, + NS, + ); - // force saving after device change - await this.savePeriodicState(); + // update records on rejoin in case anything has changed (like neighbor for routing) + this.address16ToAddress64.set(source16, source64); + const device = this.deviceTable.get(source64)!; + device.address16 = source16; + device.capabilities = capabilities; + device.neighbor = neighbor; } - return [status, newAddress16, requiresTransportKey]; + // force saving after device change + await this.savePeriodicState(); } /** @@ -1589,6 +1444,8 @@ export class StackContext { // await this.nwkHandler.sendPeriodicManyToOneRouteRequest(); // force saving after device change await this.savePeriodicState(); + } else { + logger.debug(() => `DEVICE_LEFT[src=${source16}:${source64}] Unknown device, tables likely corrupted`, NS); } } } diff --git a/src/zigbee/zigbee-aps.ts b/src/zigbee/zigbee-aps.ts index 3b536d5..11f4272 100644 --- a/src/zigbee/zigbee-aps.ts +++ b/src/zigbee/zigbee-aps.ts @@ -26,14 +26,6 @@ export const enum ZigbeeAPSConsts { CMD_REQ_NWK_KEY = 0x01, CMD_REQ_APP_KEY = 0x02, - CMD_UPDATE_STANDARD_SEC_REJOIN = 0x00, - CMD_UPDATE_STANDARD_UNSEC_JOIN = 0x01, - CMD_UPDATE_LEAVE = 0x02, - CMD_UPDATE_STANDARD_UNSEC_REJOIN = 0x03, - CMD_UPDATE_HIGH_SEC_REJOIN = 0x04, - CMD_UPDATE_HIGH_UNSEC_JOIN = 0x05, - CMD_UPDATE_HIGH_UNSEC_REJOIN = 0x07, - FCF_FRAME_TYPE = 0x03, FCF_DELIVERY_MODE = 0x0c, /** Zigbee 2004 and earlier. */ diff --git a/test/compliance/aps.test.ts b/test/compliance/aps.test.ts index fdab2c2..2515f0d 100644 --- a/test/compliance/aps.test.ts +++ b/test/compliance/aps.test.ts @@ -29,6 +29,7 @@ import { ZigbeeAPSFragmentation, ZigbeeAPSFrameType, type ZigbeeAPSHeader, + ZigbeeAPSUpdateDeviceStatus, } from "../../src/zigbee/zigbee-aps.js"; import { decodeZigbeeNWKFrameControl, @@ -1151,7 +1152,7 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { offset += 8; payload.writeUInt16LE(device16, offset); offset += 2; - payload.writeUInt8(ZigbeeAPSConsts.CMD_UPDATE_STANDARD_UNSEC_JOIN, offset); + payload.writeUInt8(ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_UNSECURED_JOIN, offset); const macHeader: MACHeader = { frameControl: createMACFrameControl(MACFrameType.DATA, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), @@ -1252,7 +1253,7 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { offset += 8; payload.writeUInt16LE(device16, offset); offset += 2; - payload.writeUInt8(ZigbeeAPSConsts.CMD_UPDATE_LEAVE, offset); + payload.writeUInt8(ZigbeeAPSUpdateDeviceStatus.DEVICE_LEFT, offset); const macHeader: MACHeader = { frameControl: createMACFrameControl(MACFrameType.DATA, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), @@ -1323,7 +1324,7 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { offset += 8; payload.writeUInt16LE(newShort, offset); offset += 2; - payload.writeUInt8(ZigbeeAPSConsts.CMD_UPDATE_STANDARD_UNSEC_REJOIN, offset); + payload.writeUInt8(ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_TRUST_CENTER_REJOIN, offset); const macHeader: MACHeader = { frameControl: createMACFrameControl(MACFrameType.DATA, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), @@ -1376,7 +1377,7 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { expect(entry).toBeDefined(); expect(entry?.address16).toStrictEqual(newShort); expect(context.address16ToAddress64.get(newShort)).toStrictEqual(device64); - expect(frames).toHaveLength(0); + expect(frames).toHaveLength(1); mockMACHandlerCallbacks.onSendFrame = vi.fn(); }); diff --git a/test/compliance/bdb.test.ts b/test/compliance/bdb.test.ts index e4101a4..5e1251c 100644 --- a/test/compliance/bdb.test.ts +++ b/test/compliance/bdb.test.ts @@ -166,10 +166,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { allocateAddress: true, }; - const [status, assignedAddress] = await context.associate(undefined, device64, true, capabilities, true); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(assignedAddress).not.toStrictEqual(ZigbeeConsts.COORDINATOR_ADDRESS); + await context.associate(0x1234, device64, capabilities, true, true); const mutatedPanId = 0x7a7b; const mutatedChannel = 21; @@ -193,7 +190,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { expect(restored.netParams.channel).toStrictEqual(mutatedChannel); expect(restored.netParams.nwkUpdateId).toStrictEqual(mutatedUpdateId); expect(restored.deviceTable.has(device64)).toStrictEqual(true); - expect(restored.address16ToAddress64.get(assignedAddress)).toStrictEqual(device64); + expect(restored.address16ToAddress64.get(0x1234)).toStrictEqual(device64); restored.disallowJoins(); }); @@ -249,33 +246,6 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { expect(context.trustCenterPolicies.allowJoins).toStrictEqual(true); expect(decoded.header.superframeSpec?.associationPermit).toStrictEqual(true); }); - - it("assigns unique short addresses to joining devices", async () => { - context.allowJoins(60, true); - - const capabilities: MACCapabilities = { - alternatePANCoordinator: false, - deviceType: 1, - powerSource: 1, - rxOnWhenIdle: true, - securityCapability: true, - allocateAddress: true, - }; - - const deviceA = 0x00124b0000aaaaf1n; - const deviceB = 0x00124b0000bbb0f2n; - - const [statusA, addressA] = await context.associate(undefined, deviceA, true, capabilities, true); - const [statusB, addressB] = await context.associate(undefined, deviceB, true, capabilities, true); - - expect(statusA).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(statusB).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(addressA).not.toStrictEqual(ZigbeeConsts.COORDINATOR_ADDRESS); - expect(addressB).not.toStrictEqual(ZigbeeConsts.COORDINATOR_ADDRESS); - expect(addressA).not.toStrictEqual(addressB); - expect(context.deviceTable.get(deviceA)?.address16).toStrictEqual(addressA); - expect(context.deviceTable.get(deviceB)?.address16).toStrictEqual(addressB); - }); }); /** @@ -296,10 +266,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { allocateAddress: true, }; - const [status, address16] = await context.associate(undefined, router64, true, capabilities, true); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(address16).not.toStrictEqual(ZigbeeConsts.COORDINATOR_ADDRESS); + await context.associate(0x1234, router64, capabilities, true, true); const entry = context.deviceTable.get(router64); diff --git a/test/compliance/integration.test.ts b/test/compliance/integration.test.ts index e024974..51927ee 100644 --- a/test/compliance/integration.test.ts +++ b/test/compliance/integration.test.ts @@ -1156,41 +1156,6 @@ describe("Integration and End-to-End Compliance", () => { await new Promise((resolve) => setImmediate(resolve)); }); - it("restores unknown devices by issuing rejoin responses", async () => { - const device16 = 0x3466; - const device64 = 0x00124b00deaf1104n; - const rejoinCaps: MACCapabilities = { - alternatePANCoordinator: false, - deviceType: 0, - powerSource: 1, - rxOnWhenIdle: false, - securityCapability: true, - allocateAddress: true, - }; - const frames: Buffer[] = []; - mockMACHandlerCallbacks.onSendFrame = vi.fn((payload: Buffer) => { - frames.push(Buffer.from(payload)); - return Promise.resolve(); - }); - - const { macHeader, nwkHeader } = buildRejoinHeaders(device16, device64, true, 0x82, 0x52); - const payload = Buffer.from([ZigbeeNWKCommandId.REJOIN_REQ, encodeMACCapabilities(rejoinCaps)]); - - await nwkHandler.processCommand(payload, macHeader, nwkHeader); - - const restored = context.deviceTable.get(device64); - expect(restored).not.toBeUndefined(); - expect(restored!.address16).toStrictEqual(device16); - expect(restored!.authorized).toStrictEqual(false); - expect(context.address16ToAddress64.get(device16)).toStrictEqual(device64); - - const indirectQueue = context.indirectTransmissions.get(device64); - expect(indirectQueue).not.toBeUndefined(); - expect(indirectQueue!.length).toStrictEqual(1); - - await new Promise((resolve) => setImmediate(resolve)); - }); - it("secures rejoin responses with the network key for secure rejoins", async () => { const device16 = 0x4177; const device64 = 0x00124b00deaf1105n; diff --git a/test/compliance/mac.test.ts b/test/compliance/mac.test.ts index ed16144..4d79fb6 100644 --- a/test/compliance/mac.test.ts +++ b/test/compliance/mac.test.ts @@ -658,10 +658,10 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { expect(baseFrame.panCoordinator).toStrictEqual(true); expect(baseFrame.associationPermit).toStrictEqual(false); - context.associationPermit = true; + context.macAssociationPermit = true; const permissive = await generateBeacon(); expect(permissive.header.superframeSpec!.associationPermit).toStrictEqual(true); - context.associationPermit = false; + context.macAssociationPermit = false; }); it("disables GTS and leaves pending address lists empty", async () => { @@ -939,7 +939,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { await macHandler.processCommand(Buffer.from([defaultCapabilities]), buildAssocHeader()); expect(associateSpy).toHaveBeenCalledTimes(1); - const capabilitiesArg = associateSpy.mock.calls[0][3] as MACCapabilities; + const capabilitiesArg = associateSpy.mock.calls[0][2] as MACCapabilities; expect(capabilitiesArg).toStrictEqual({ alternatePANCoordinator: true, deviceType: 1, @@ -991,27 +991,6 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { randomSpy.mockRestore(); }); - it("propagates PAN_FULL status to association responses", async () => { - const associateSpy = vi.spyOn(context, "associate").mockResolvedValue([MACAssociationStatus.PAN_FULL, 0xffff, false]); - const sendCommandSpy = vi.spyOn(macHandler, "sendCommand").mockResolvedValue(true); - - await macHandler.processCommand(Buffer.from([defaultCapabilities]), buildAssocHeader()); - - const pending = context.pendingAssociations.get(device64); - expect(pending).not.toBeUndefined(); - - await pending!.sendResp(); - - expect(sendCommandSpy).toHaveBeenCalledTimes(1); - const payload = sendCommandSpy.mock.calls[0][4]; - expect(payload.readUInt16LE(0)).toStrictEqual(0xffff); - expect(payload.readUInt8(2)).toStrictEqual(MACAssociationStatus.PAN_FULL); - expect(mockMACHandlerCallbacks.onAPSSendTransportKeyNWK).not.toHaveBeenCalled(); - expect(context.deviceTable.has(device64)).toStrictEqual(false); - - associateSpy.mockRestore(); - }); - it("denies association when joins are not permitted", async () => { const sendCommandSpy = vi.spyOn(macHandler, "sendCommand").mockResolvedValue(true); diff --git a/test/compliance/nwk.test.ts b/test/compliance/nwk.test.ts index c4eba0f..631b60c 100644 --- a/test/compliance/nwk.test.ts +++ b/test/compliance/nwk.test.ts @@ -1984,37 +1984,6 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { expect(updatedDevice.capabilities).toStrictEqual(decodedCapabilities); }); - it("assigns a new network address and signals conflict when a rejoin collides", async () => { - const conflicting64 = 0x00124b00ccddee22n; - context.deviceTable.set(conflicting64, { - ...defaultDeviceTableEntry(), - address16: rejoiner16, - capabilities: { ...baseCapabilities }, - authorized: true, - neighbor: true, - }); - context.address16ToAddress64.set(rejoiner16, conflicting64); - const currentDevice = context.deviceTable.get(rejoiner64)!; - currentDevice.address16 = 0x5522; - context.address16ToAddress64.set(0x5522, rejoiner64); - - const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.25); - - const { nwkPayload } = await captureRejoinResponse(encodeMACCapabilities(currentDevice.capabilities!)); - - randomSpy.mockRestore(); - - const commandId = nwkPayload.readUInt8(0); - const assignedAddress = nwkPayload.readUInt16LE(1); - const status = nwkPayload.readUInt8(3); - - expect(commandId).toStrictEqual(ZigbeeNWKCommandId.REJOIN_RESP); - expect(status).toStrictEqual(ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT); - expect(assignedAddress).not.toStrictEqual(rejoiner16); - expect(assignedAddress).toBeLessThan(ZigbeeConsts.BCAST_MIN); - expect(context.deviceTable.get(rejoiner64)?.address16).toStrictEqual(0x5522); - }); - it("denies unsecured rejoins from unauthorized devices", async () => { const device = context.deviceTable.get(rejoiner64)!; device.authorized = false; diff --git a/test/compliance/security.test.ts b/test/compliance/security.test.ts index ae67f17..3b68ecf 100644 --- a/test/compliance/security.test.ts +++ b/test/compliance/security.test.ts @@ -17,7 +17,6 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { encodeMACCapabilities, - MACAssociationStatus, type MACCapabilities, MACCommandId, MACFrameAddressMode, @@ -999,53 +998,6 @@ describe("Zigbee 4.0 Security Compliance", () => { expect(() => context.addInstallCode(device64, codeVector)).toThrowError("Invalid install code CRC"); }); - - it("enforces install code policy when required by the trust center", async () => { - const device64 = 0x00124b00ffee0303n; - const caps: MACCapabilities = { - alternatePANCoordinator: false, - deviceType: 0, - powerSource: 1, - rxOnWhenIdle: true, - securityCapability: true, - allocateAddress: true, - }; - - context.trustCenterPolicies.installCode = InstallCodePolicy.REQUIRED; - context.allowJoins(60, true); - - const [statusWithoutCode] = await context.associate(undefined, device64, true, caps, true); - - expect(statusWithoutCode).toStrictEqual(MACAssociationStatus.PAN_ACCESS_DENIED); - - context.addInstallCode(device64, codeVector); - - const [statusWithCode, assignedAddress] = await context.associate(undefined, device64, true, caps, true); - - expect(statusWithCode).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(assignedAddress).not.toStrictEqual(0xffff); - expect(context.getAppLinkKey(device64, context.netParams.eui64)).toStrictEqual(linkKeyVector); - }); - - it("allows joins without install codes when policy is not required", async () => { - const device64 = 0x00124b00ffee0404n; - const caps: MACCapabilities = { - alternatePANCoordinator: false, - deviceType: 0, - powerSource: 1, - rxOnWhenIdle: true, - securityCapability: true, - allocateAddress: true, - }; - - context.trustCenterPolicies.installCode = InstallCodePolicy.NOT_REQUIRED; - context.allowJoins(60, true); - - const [status, assignedAddress] = await context.associate(undefined, device64, true, caps, true); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(assignedAddress).not.toStrictEqual(0xffff); - }); }); /** diff --git a/test/drivers/ot-rcp-driver.test.ts b/test/drivers/ot-rcp-driver.test.ts index 06ed846..a279aaf 100644 --- a/test/drivers/ot-rcp-driver.test.ts +++ b/test/drivers/ot-rcp-driver.test.ts @@ -47,7 +47,6 @@ import { decodeZigbeeNWKHeader, decodeZigbeeNWKPayload, ZigbeeNWKCommandId, - ZigbeeNWKConsts, ZigbeeNWKFrameType, type ZigbeeNWKHeader, type ZigbeeNWKLinkStatus, @@ -843,296 +842,7 @@ describe("OT RCP Driver", () => { expect(exitCommissioningModeSpy).toHaveBeenCalledTimes(1); // cleared timer }); - it("associates", async () => { - const assignNetworkAddressSpy = vi.spyOn(driver.context, "assignNetworkAddress"); - - //-- INITIAL JOIN - // joins not allowed - let network16 = driver.context.assignNetworkAddress(); - let network64 = randomBigInt(); - let [status, newAddr16] = await driver.context.associate(network16, network64, true, structuredClone(COMMON_FFD_MAC_CAP), true); - - expect(status).toStrictEqual(MACAssociationStatus.PAN_ACCESS_DENIED); - expect(newAddr16).toStrictEqual(0xffff); - - driver.context.allowJoins(0xfe, true); - - // neighbor device, joins allowed - [status, newAddr16] = await driver.context.associate(network16, network64, true, structuredClone(COMMON_FFD_MAC_CAP), true); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); - expect(driver.context.deviceTable.get(network64)).toBeDefined(); - expect(driver.context.address16ToAddress64.get(network16)).toBeDefined(); - expect(driver.context.indirectTransmissions.get(network64)).toBeUndefined(); - expect(driver.context.sourceRouteTable.get(network16)).toBeUndefined(); - expect(driver.context.pendingAssociations.get(network64)).toBeUndefined(); - - // neighbor device, forced denied - network16 = driver.context.assignNetworkAddress(); - network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, true, structuredClone(COMMON_RFD_MAC_CAP), true, true); - - expect(status).toStrictEqual(MACAssociationStatus.PAN_ACCESS_DENIED); - expect(newAddr16).toStrictEqual(0xffff); - - // neighbor device, forced allowed - network16 = driver.context.assignNetworkAddress(); - network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, true, structuredClone(COMMON_RFD_MAC_CAP), true, false, true); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); - - // device, joins allowed - network16 = driver.context.assignNetworkAddress(); - network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, true, structuredClone(COMMON_RFD_MAC_CAP), false); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); - expect(driver.context.deviceTable.get(network64)).toBeDefined(); - expect(driver.context.address16ToAddress64.get(network16)).toBeDefined(); - expect(driver.context.indirectTransmissions.get(network64)).toBeDefined(); - expect(driver.context.sourceRouteTable.get(network16)).toBeUndefined(); - expect(driver.context.pendingAssociations.get(network64)).toBeUndefined(); - - // device, forced denied - network16 = driver.context.assignNetworkAddress(); - network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, true, structuredClone(COMMON_RFD_MAC_CAP), false, true); - - expect(status).toStrictEqual(MACAssociationStatus.PAN_ACCESS_DENIED); - expect(newAddr16).toStrictEqual(0xffff); - - // device, forced allowed - network16 = driver.context.assignNetworkAddress(); - network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, true, structuredClone(COMMON_RFD_MAC_CAP), false, false, true); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); - - // conflict, already present - let device = driver.context.deviceTable.values().next().value!; - device.authorized = true; - network16 = device.address16; - network64 = driver.context.address16ToAddress64.get(network16)!; - [status, newAddr16] = await driver.context.associate(network16, network64, true, structuredClone(COMMON_RFD_MAC_CAP), true); - - expect(status).toStrictEqual(ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT); - expect(newAddr16).toStrictEqual(0xffff); - device.authorized = false; - - // conflict, on network16 - network16 = driver.context.deviceTable.values().next().value!.address16; - network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, true, structuredClone(COMMON_RFD_MAC_CAP), true); - - expect(status).toStrictEqual(ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT); - expect(newAddr16).not.toStrictEqual(network16); - expect(newAddr16).not.toStrictEqual(0xffff); - - // conflict, on network16/network64 - device = driver.context.deviceTable.values().next().value!; - device.authorized = true; - network16 = device.address16; - network64 = driver.context.address16ToAddress64.get(network16)!; - [status, newAddr16] = await driver.context.associate(network16, network64, true, structuredClone(COMMON_FFD_MAC_CAP), true); - - expect(status).toStrictEqual(ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT); - expect(newAddr16).toStrictEqual(0xffff); - device.authorized = false; - - // by network64 only - network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(undefined, network64, true, structuredClone(COMMON_RFD_MAC_CAP), true); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).not.toStrictEqual(0xffff); - - // by network16 only - network16 = driver.context.deviceTable.values().next().value!.address16; - [status, newAddr16] = await driver.context.associate(network16, undefined, true, structuredClone(COMMON_FFD_MAC_CAP), false); - - expect(status).toStrictEqual(ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT); - expect(newAddr16).not.toStrictEqual(network16); - expect(newAddr16).not.toStrictEqual(0xffff); - - // mocked PAN full by network16 only - network64 = randomBigInt(); - assignNetworkAddressSpy.mockReturnValueOnce(0xffff); - [status, newAddr16] = await driver.context.associate(undefined, network64, true, structuredClone(COMMON_RFD_MAC_CAP), true); - - expect(status).toStrictEqual(MACAssociationStatus.PAN_FULL); - expect(newAddr16).toStrictEqual(0xffff); - - // mocked PAN full by network64 only - network16 = driver.context.deviceTable.values().next().value!.address16; - assignNetworkAddressSpy.mockReturnValueOnce(0xffff); - [status, newAddr16] = await driver.context.associate(network16, undefined, true, structuredClone(COMMON_FFD_MAC_CAP), false); - - expect(status).toStrictEqual(MACAssociationStatus.PAN_FULL); - expect(newAddr16).toStrictEqual(0xffff); - - driver.context.disallowJoins(); // doesn't matter, but check with disabled just to confirm - - //-- REJOIN - expect(driver.context.deviceTable.size).toBeGreaterThan(0); - expect(driver.context.address16ToAddress64.size).toBeGreaterThan(0); - - // unknown neighbor device - network16 = driver.context.assignNetworkAddress(); - network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, false, structuredClone(COMMON_FFD_MAC_CAP), true); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); - - // unknown neighbor device, forced denied - network16 = driver.context.assignNetworkAddress(); - network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, false, structuredClone(COMMON_RFD_MAC_CAP), true, true); - - expect(status).toStrictEqual(MACAssociationStatus.PAN_ACCESS_DENIED); - expect(newAddr16).toStrictEqual(0xffff); - - // unknown neighbor device, forced allowed - network16 = driver.context.assignNetworkAddress(); - network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, false, structuredClone(COMMON_RFD_MAC_CAP), true, false, true); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); - - // unknown device - network16 = driver.context.assignNetworkAddress(); - network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, false, structuredClone(COMMON_RFD_MAC_CAP), false); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); - - // unknown neighbor device, forced denied - network16 = driver.context.assignNetworkAddress(); - network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, false, structuredClone(COMMON_RFD_MAC_CAP), false, true); - - expect(status).toStrictEqual(MACAssociationStatus.PAN_ACCESS_DENIED); - expect(newAddr16).toStrictEqual(0xffff); - - // unknown neighbor device, forced allowed - network16 = driver.context.assignNetworkAddress(); - network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate( - network16, - network64, - false, - structuredClone(COMMON_RFD_MAC_CAP), - false, - false, - true, - ); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); - - // existing neighbor device - network16 = driver.context.deviceTable.values().next().value!.address16; - network64 = driver.context.address16ToAddress64.get(network16)!; - [status, newAddr16] = await driver.context.associate(network16, network64, false, structuredClone(COMMON_FFD_MAC_CAP), true); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); - - // existing neighbor device, forced denied - network16 = driver.context.deviceTable.values().next().value!.address16; - network64 = driver.context.address16ToAddress64.get(network16)!; - [status, newAddr16] = await driver.context.associate(network16, network64, false, structuredClone(COMMON_RFD_MAC_CAP), true, true); - - expect(status).toStrictEqual(MACAssociationStatus.PAN_ACCESS_DENIED); - expect(newAddr16).toStrictEqual(0xffff); - - // existing neighbor device, forced allowed - network16 = driver.context.deviceTable.values().next().value!.address16; - network64 = driver.context.address16ToAddress64.get(network16)!; - [status, newAddr16] = await driver.context.associate(network16, network64, false, structuredClone(COMMON_RFD_MAC_CAP), true, false, true); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); - - // existing device - network16 = driver.context.deviceTable.values().next().value!.address16; - network64 = driver.context.address16ToAddress64.get(network16)!; - [status, newAddr16] = await driver.context.associate(network16, network64, false, structuredClone(COMMON_RFD_MAC_CAP), false); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); - - // existing device, forced denied - network16 = driver.context.deviceTable.values().next().value!.address16; - network64 = driver.context.address16ToAddress64.get(network16)!; - [status, newAddr16] = await driver.context.associate(network16, network64, false, structuredClone(COMMON_RFD_MAC_CAP), false, true); - - expect(status).toStrictEqual(MACAssociationStatus.PAN_ACCESS_DENIED); - expect(newAddr16).toStrictEqual(0xffff); - - // existing device, forced allowed - network16 = driver.context.deviceTable.values().next().value!.address16; - network64 = driver.context.address16ToAddress64.get(network16)!; - [status, newAddr16] = await driver.context.associate( - network16, - network64, - false, - structuredClone(COMMON_RFD_MAC_CAP), - false, - false, - true, - ); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); - - // existing, conflicting, on network16 - network16 = driver.context.deviceTable.values().next().value!.address16; - network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, false, structuredClone(COMMON_RFD_MAC_CAP), true); - - expect(status).toStrictEqual(ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT); - expect(newAddr16).not.toStrictEqual(network16); - expect(newAddr16).not.toStrictEqual(0xffff); - - // existing, by network64 only - network64 = driver.context.address16ToAddress64.get(network16)!; - [status, newAddr16] = await driver.context.associate(undefined, network64, false, structuredClone(COMMON_RFD_MAC_CAP), true); - - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).not.toStrictEqual(0xffff); - - // existing, by network16 only - network16 = driver.context.deviceTable.values().next().value!.address16; - [status, newAddr16] = await driver.context.associate(network16, undefined, false, structuredClone(COMMON_RFD_MAC_CAP), true); - - expect(status).toStrictEqual(ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT); - expect(newAddr16).not.toStrictEqual(network16); - expect(newAddr16).not.toStrictEqual(0xffff); - - // existing device, by network64 only, mocked PAN full - network64 = randomBigInt(); - assignNetworkAddressSpy.mockReturnValueOnce(0xffff); - [status, newAddr16] = await driver.context.associate(undefined, network64, false, structuredClone(COMMON_RFD_MAC_CAP), true); - - expect(status).toStrictEqual(MACAssociationStatus.PAN_FULL); - expect(newAddr16).toStrictEqual(0xffff); - - // existing device, by network16 only, mocked PAN full - network16 = driver.context.deviceTable.values().next().value!.address16; - assignNetworkAddressSpy.mockReturnValueOnce(0xffff); - [status, newAddr16] = await driver.context.associate(network16, undefined, false, structuredClone(COMMON_FFD_MAC_CAP), false); - - expect(status).toStrictEqual(MACAssociationStatus.PAN_FULL); - expect(newAddr16).toStrictEqual(0xffff); - }); + it.todo("associates", async () => {}); it("disassociates", async () => { // no-op, not relevant for this test @@ -1142,10 +852,8 @@ describe("OT RCP Driver", () => { // neighbor FFD let network16 = driver.context.assignNetworkAddress(); let network64 = randomBigInt(); - let [status, newAddr16] = await driver.context.associate(network16, network64, true, structuredClone(COMMON_FFD_MAC_CAP), true); + await driver.context.associate(network16, network64, structuredClone(COMMON_FFD_MAC_CAP), true, true); - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); expect(driver.context.deviceTable.get(network64)).toBeDefined(); expect(driver.context.address16ToAddress64.get(network16)).toBeDefined(); expect(driver.context.indirectTransmissions.get(network64)).toBeUndefined(); @@ -1163,10 +871,8 @@ describe("OT RCP Driver", () => { // FFD network16 = driver.context.assignNetworkAddress(); network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, true, structuredClone(COMMON_FFD_MAC_CAP), false); + await driver.context.associate(network16, network64, structuredClone(COMMON_FFD_MAC_CAP), false, true); - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); expect(driver.context.deviceTable.get(network64)).toBeDefined(); expect(driver.context.address16ToAddress64.get(network16)).toBeDefined(); expect(driver.context.indirectTransmissions.get(network64)).toBeUndefined(); @@ -1184,10 +890,8 @@ describe("OT RCP Driver", () => { // neighbor RFD network16 = driver.context.assignNetworkAddress(); network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, true, structuredClone(COMMON_RFD_MAC_CAP), true); + await driver.context.associate(network16, network64, structuredClone(COMMON_RFD_MAC_CAP), true, true); - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); expect(driver.context.deviceTable.get(network64)).toBeDefined(); expect(driver.context.address16ToAddress64.get(network16)).toBeDefined(); expect(driver.context.indirectTransmissions.get(network64)).toBeDefined(); @@ -1205,10 +909,8 @@ describe("OT RCP Driver", () => { // RFD network16 = driver.context.assignNetworkAddress(); network64 = randomBigInt(); - [status, newAddr16] = await driver.context.associate(network16, network64, true, structuredClone(COMMON_RFD_MAC_CAP), false); + await driver.context.associate(network16, network64, structuredClone(COMMON_RFD_MAC_CAP), false, true); - expect(status).toStrictEqual(MACAssociationStatus.SUCCESS); - expect(newAddr16).toStrictEqual(network16); expect(driver.context.deviceTable.get(network64)).toBeDefined(); expect(driver.context.address16ToAddress64.get(network16)).toBeDefined(); expect(driver.context.indirectTransmissions.get(network64)).toBeDefined(); @@ -2335,7 +2037,7 @@ describe("OT RCP Driver", () => { const nwkDest64 = 8458932590n; driver.context.allowJoins(5, true); - await driver.context.associate(nwkDest16, nwkDest64, true, structuredClone(COMMON_FFD_MAC_CAP), true); + await driver.context.associate(nwkDest16, nwkDest64, structuredClone(COMMON_FFD_MAC_CAP), true, true); waitForTIDSpy.mockRejectedValueOnce(new Error("Failed with status=NO_ACK")); await expect( @@ -2444,7 +2146,7 @@ describe("OT RCP Driver", () => { const clusterId = 0; driver.context.allowJoins(0x5, true); - await driver.context.associate(nwkDest16, nwkDest64, true, structuredClone(COMMON_FFD_MAC_CAP), true); + await driver.context.associate(nwkDest16, nwkDest64, structuredClone(COMMON_FFD_MAC_CAP), true, true); const p = driver.sendZDO(payload, nwkDest16, nwkDest64, clusterId); driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup, SpinelStatus.OK), "utf8", () => {}); @@ -2668,7 +2370,7 @@ describe("OT RCP Driver", () => { const sourceEndpoint = 1; driver.context.allowJoins(0x5, true); - await driver.context.associate(nwkDest16, nwkDest64, true, structuredClone(COMMON_FFD_MAC_CAP), true); + await driver.context.associate(nwkDest16, nwkDest64, structuredClone(COMMON_FFD_MAC_CAP), true, true); const p = driver.sendUnicast(payload, profileId, clusterId, nwkDest16, nwkDest64, destEndpoint, sourceEndpoint); driver.parser._transform(makeSpinelLastStatus(nextTidFromStartup, SpinelStatus.OK), "utf8", () => {}); diff --git a/test/zigbee-stack/aps-handler.test.ts b/test/zigbee-stack/aps-handler.test.ts index fdfd286..2466490 100644 --- a/test/zigbee-stack/aps-handler.test.ts +++ b/test/zigbee-stack/aps-handler.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest"; import { logger } from "../../src/utils/logger.js"; -import { MACAssociationStatus, type MACHeader } from "../../src/zigbee/mac.js"; +import type { MACHeader } from "../../src/zigbee/mac.js"; import { GlobalTlv } from "../../src/zigbee/tlvs.js"; import { makeKeyedHashByType, registerDefaultHashedKeys, ZigbeeConsts, ZigbeeKeyType } from "../../src/zigbee/zigbee.js"; import { @@ -76,13 +76,7 @@ describe("APS Handler", () => { vi.spyOn(mockContext, "nextNWKKeyFrameCounter"); vi.spyOn(mockContext, "nextTCKeyFrameCounter"); vi.spyOn(mockContext, "computeDeviceLQA").mockReturnValue(150); - vi.spyOn(mockContext, "associate").mockImplementation( - (source16, _source64, _initialJoin, _capabilities, _neighbor, _denyOverride, _allowOverride) => { - const assigned16 = source16 ?? 0x1234; - - return Promise.resolve([MACAssociationStatus.SUCCESS, assigned16, true]); - }, - ); + vi.spyOn(mockContext, "associate").mockResolvedValue(); vi.spyOn(mockContext, "disassociate").mockResolvedValue(undefined); mockMACCallbacks = { diff --git a/test/zigbee-stack/mac-handler.test.ts b/test/zigbee-stack/mac-handler.test.ts index d536f96..b57aaae 100644 --- a/test/zigbee-stack/mac-handler.test.ts +++ b/test/zigbee-stack/mac-handler.test.ts @@ -64,19 +64,7 @@ describe("MACHandler", () => { mockContext = new StackContext(mockStackContextCallbacks, join(saveDir, "zoh.save"), netParams); - vi.spyOn(mockContext, "associate").mockImplementation( - (_source16, _source64, _initialJoin, _capabilities, _neighbor, denyOverride, allowOverride) => { - if (denyOverride) { - return Promise.resolve([MACAssociationStatus.PAN_ACCESS_DENIED, 0xffff, false]); - } - - if (allowOverride) { - return Promise.resolve([MACAssociationStatus.SUCCESS, 0x1234, true]); - } - - return Promise.resolve([MACAssociationStatus.SUCCESS, 0x1234, true]); - }, - ); + vi.spyOn(mockContext, "associate").mockResolvedValue(); vi.spyOn(mockContext, "disassociate").mockResolvedValue(undefined); mockCallbacks = { @@ -326,12 +314,13 @@ describe("MACHandler", () => { describe("processCommand", () => { it("should dispatch ASSOC_REQ to handler", async () => { + mockContext.allowJoins(0xfe, true); + const macHeader: MACHeader = { - frameControl: createMACFrameControl(MACFrameType.CMD, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), + frameControl: createMACFrameControl(MACFrameType.CMD, MACFrameAddressMode.SHORT, MACFrameAddressMode.EXT), sequenceNumber: 1, destinationPANId: 0x1a62, destination16: 0x0000, - source16: 0xffff, source64: 0x00124b0098765432n, commandId: MACCommandId.ASSOC_REQ, fcs: 0, @@ -392,6 +381,7 @@ describe("MACHandler", () => { describe("processAssocReq", () => { it("should process association request from new device", async () => { mockContext.allowJoins(0xfe, true); + const assignSpy = vi.spyOn(mockContext, "assignNetworkAddress").mockReturnValueOnce(0x1234); const macHeader: MACHeader = { frameControl: createMACFrameControl(MACFrameType.CMD, MACFrameAddressMode.SHORT, MACFrameAddressMode.SHORT), @@ -406,11 +396,13 @@ describe("MACHandler", () => { const data = Buffer.from([0x8e]); // capabilities: rxOnWhenIdle=true, deviceType=FFD, powerSource=mains, securityCapability=true, allocateAddress=true await macHandler.processAssocReq(data, macHeader); - expect(mockContext.associate).toHaveBeenCalledWith(undefined, 0x00124b0098765432n, true, expect.any(Object), true, false); + expect(mockContext.associate).toHaveBeenCalledWith(0x1234, 0x00124b0098765432n, expect.any(Object), true, true); expect(mockContext.pendingAssociations.has(0x00124b0098765432n)).toStrictEqual(true); + + assignSpy.mockRestore(); }); - it("should process association request from known device (rejoin)", async () => { + it("should process association request from known device", async () => { mockContext.allowJoins(0xfe, true); const dest64 = 0x00124b0098765432n; @@ -437,7 +429,8 @@ describe("MACHandler", () => { const data = Buffer.from([0x8e]); await macHandler.processAssocReq(data, macHeader); - expect(mockContext.associate).toHaveBeenCalledWith(dest16, dest64, false, expect.any(Object), true, false); + // ignored + expect(mockContext.associate).not.toHaveBeenCalled(); }); it("should handle association request without source64", async () => { @@ -510,7 +503,7 @@ describe("MACHandler", () => { }); it("should include association permit in beacon", async () => { - mockContext.associationPermit = true; + mockContext.macAssociationPermit = true; getOnSendFrameMock().mockClear(); const macHeader: MACHeader = { diff --git a/test/zigbee-stack/nwk-handler.test.ts b/test/zigbee-stack/nwk-handler.test.ts index c881889..d258df5 100644 --- a/test/zigbee-stack/nwk-handler.test.ts +++ b/test/zigbee-stack/nwk-handler.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import type { MockInstance } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { logger } from "../../src/utils/logger.js"; -import { MACAssociationStatus, type MACCapabilities, type MACHeader } from "../../src/zigbee/mac.js"; +import type { MACCapabilities, MACHeader } from "../../src/zigbee/mac.js"; import { makeKeyedHashByType, registerDefaultHashedKeys, ZigbeeConsts, ZigbeeKeyType } from "../../src/zigbee/zigbee.js"; import { ZigbeeNWKCommandId, ZigbeeNWKConsts, type ZigbeeNWKHeader } from "../../src/zigbee/zigbee-nwk.js"; import { MACHandler, type MACHandlerCallbacks } from "../../src/zigbee-stack/mac-handler.js"; @@ -60,7 +60,7 @@ describe("NWK Handler", () => { // Spy on context methods to track calls while preserving functionality vi.spyOn(mockContext, "nextNWKKeyFrameCounter"); vi.spyOn(mockContext, "nextTCKeyFrameCounter"); - associateSpy = vi.spyOn(mockContext, "associate").mockResolvedValue([MACAssociationStatus.SUCCESS, 0x1234, false]); + associateSpy = vi.spyOn(mockContext, "associate").mockResolvedValue(); vi.spyOn(mockContext, "disassociate").mockResolvedValue(undefined); mockMACCallbacks = { @@ -563,19 +563,8 @@ describe("NWK Handler", () => { await nwkHandler.processCommand(payload, macHeader, nwkHeader); - // Should have called associate callback - expect(associateSpy).toHaveBeenCalledWith( - 0x1234, - 0x00124b0012345678n, - false, // rejoin (not initial join) - expect.objectContaining({ - deviceType: 1, - rxOnWhenIdle: true, - allocateAddress: true, - }), // capabilities - true, // neighbor - true, // denyOverride (security is implicitly false, checks source64 vs trusted center) - ); + // Should not have called associate callback + expect(associateSpy).not.toHaveBeenCalled(); // Should have sent rejoin response expect(sendFrameSpy).toHaveBeenCalled(); @@ -647,8 +636,7 @@ describe("NWK Handler", () => { } as ZigbeeNWKHeader, ); - expect(associateSpy).toHaveBeenCalled(); - expect(associateSpy.mock.calls[0]?.[5]).toStrictEqual(true); + expect(associateSpy).not.toHaveBeenCalled(); }); }); From 5d7dfdca3139ff68f448ed5392a5da3b25aad5fc Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sun, 8 Feb 2026 20:21:15 +0100 Subject: [PATCH 7/9] fix: minor cleanup --- src/zigbee-stack/aps-handler.ts | 120 ++++++++++++++++++-------------- 1 file changed, 69 insertions(+), 51 deletions(-) diff --git a/src/zigbee-stack/aps-handler.ts b/src/zigbee-stack/aps-handler.ts index 5996db1..16115ef 100644 --- a/src/zigbee-stack/aps-handler.ts +++ b/src/zigbee-stack/aps-handler.ts @@ -1610,60 +1610,78 @@ export class APSHandler { NS, ); - if (status === ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_SECURED_REJOIN) { - await this.#context.associate(device16, device64, undefined, false, false); - this.#updateSourceRouteForChild(device16, nwkHeader.source16, nwkHeader.source64); - } else if (status === ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_UNSECURED_JOIN) { - await this.#context.associate(device16, device64, undefined, false, true); - this.#updateSourceRouteForChild(device16, nwkHeader.source16, nwkHeader.source64); - - const tApsCmdPayload = Buffer.allocUnsafe(19 + ZigbeeAPSConsts.CMD_KEY_LENGTH); - let tunnelOffset = 0; - tunnelOffset = tApsCmdPayload.writeUInt8(ZigbeeAPSCommandId.TRANSPORT_KEY, tunnelOffset); - tunnelOffset = tApsCmdPayload.writeUInt8(ZigbeeAPSConsts.CMD_KEY_STANDARD_NWK, tunnelOffset); - tunnelOffset += this.#context.netParams.networkKey.copy(tApsCmdPayload, tunnelOffset); - tunnelOffset = tApsCmdPayload.writeUInt8(this.#context.netParams.networkKeySequenceNumber, tunnelOffset); - tunnelOffset = tApsCmdPayload.writeBigUInt64LE(device64, tunnelOffset); - tunnelOffset = tApsCmdPayload.writeBigUInt64LE(this.#context.netParams.eui64, tunnelOffset); // 0xFFFFFFFFFFFFFFFF in distributed network (no TC) - - const tApsCmdFrame = encodeZigbeeAPSFrame( - { - frameControl: { - frameType: ZigbeeAPSFrameType.CMD, - deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, - ackFormat: false, - security: true, - ackRequest: false, - extendedHeader: false, + switch (status) { + case ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_SECURED_REJOIN: { + await this.#context.associate(device16, device64, undefined, false, false); + this.#updateSourceRouteForChild(device16, nwkHeader.source16, nwkHeader.source64); + + break; + } + case ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_UNSECURED_JOIN: { + await this.#context.associate(device16, device64, undefined, false, true); + this.#updateSourceRouteForChild(device16, nwkHeader.source16, nwkHeader.source64); + + const tApsCmdPayload = Buffer.allocUnsafe(19 + ZigbeeAPSConsts.CMD_KEY_LENGTH); + let tunnelOffset = 0; + tunnelOffset = tApsCmdPayload.writeUInt8(ZigbeeAPSCommandId.TRANSPORT_KEY, tunnelOffset); + tunnelOffset = tApsCmdPayload.writeUInt8(ZigbeeAPSConsts.CMD_KEY_STANDARD_NWK, tunnelOffset); + tunnelOffset += this.#context.netParams.networkKey.copy(tApsCmdPayload, tunnelOffset); + tunnelOffset = tApsCmdPayload.writeUInt8(this.#context.netParams.networkKeySequenceNumber, tunnelOffset); + tunnelOffset = tApsCmdPayload.writeBigUInt64LE(device64, tunnelOffset); + tunnelOffset = tApsCmdPayload.writeBigUInt64LE(this.#context.netParams.eui64, tunnelOffset); // 0xFFFFFFFFFFFFFFFF in distributed network (no TC) + + const tApsCmdFrame = encodeZigbeeAPSFrame( + { + frameControl: { + frameType: ZigbeeAPSFrameType.CMD, + deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, + ackFormat: false, + security: true, + ackRequest: false, + extendedHeader: false, + }, + counter: this.nextCounter(), }, - counter: this.nextCounter(), - }, - tApsCmdPayload, - { - control: { - level: ZigbeeSecurityLevel.NONE, - keyId: ZigbeeKeyType.TRANSPORT, - nonce: true, - reqVerifiedFc: false, + tApsCmdPayload, + { + control: { + level: ZigbeeSecurityLevel.NONE, + keyId: ZigbeeKeyType.TRANSPORT, + nonce: true, + reqVerifiedFc: false, + }, + frameCounter: this.#context.nextTCKeyFrameCounter(), + source64: this.#context.netParams.eui64, + micLen: 4, }, - frameCounter: this.#context.nextTCKeyFrameCounter(), - source64: this.#context.netParams.eui64, - micLen: 4, - }, - undefined, // use pre-hashed this.context.netParams.tcKey, - ); + undefined, // use pre-hashed this.context.netParams.tcKey, + ); + + await this.sendTunnel(nwkHeader.source16!, device64, tApsCmdFrame); + this.#context.markNetworkKeyTransported(device64); - await this.sendTunnel(nwkHeader.source16!, device64, tApsCmdFrame); - this.#context.markNetworkKeyTransported(device64); - } else if (status === ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_TRUST_CENTER_REJOIN) { - await this.#context.associate(device16, device64, undefined, false, false); - await this.sendTransportKeyNWK(device16, this.#context.netParams.networkKey, this.#context.netParams.networkKeySequenceNumber, device64); - this.#context.markNetworkKeyTransported(device64); - } else if (status === ZigbeeAPSUpdateDeviceStatus.DEVICE_LEFT) { - // TODO: according to spec: - // A Device Left is considered informative but SHOULD NOT be considered authoritative. - // Security related actions SHALL not be taken on receipt of this. No further processing SHALL be done. - await this.#context.disassociate(device16, device64); + break; + } + case ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_TRUST_CENTER_REJOIN: { + await this.#context.associate(device16, device64, undefined, false, false); + await this.sendTransportKeyNWK( + device16, + this.#context.netParams.networkKey, + this.#context.netParams.networkKeySequenceNumber, + device64, + ); + this.#context.markNetworkKeyTransported(device64); + + break; + } + case ZigbeeAPSUpdateDeviceStatus.DEVICE_LEFT: { + // TODO: according to spec: + // A Device Left is considered informative but SHOULD NOT be considered authoritative. + // Security related actions SHALL not be taken on receipt of this. No further processing SHALL be done. + await this.#context.disassociate(device16, device64); + + break; + } } } From 18892f609f3fefb1909420bfe235cec822ebb6db Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:11:06 +0100 Subject: [PATCH 8/9] fix: network commissioning --- src/drivers/ot-rcp-driver.ts | 3 + src/zigbee-stack/aps-handler.ts | 287 ++++++++++++++++++-------- src/zigbee-stack/nwk-handler.ts | 114 ++++++---- src/zigbee-stack/stack-context.ts | 9 + src/zigbee/tlvs.ts | 44 +++- src/zigbee/zigbee.ts | 3 + test/compliance/aps.test.ts | 1 + test/compliance/bdb.test.ts | 38 +++- test/compliance/integration.test.ts | 1 + test/compliance/mac.test.ts | 1 + test/compliance/nwk-gp.test.ts | 1 + test/compliance/nwk.test.ts | 1 + test/compliance/security.test.ts | 1 + test/drivers/ot-rcp-driver.test.ts | 18 +- test/zigbee-stack/aps-handler.test.ts | 3 +- test/zigbee-stack/nwk-handler.test.ts | 3 +- 16 files changed, 387 insertions(+), 141 deletions(-) diff --git a/src/drivers/ot-rcp-driver.ts b/src/drivers/ot-rcp-driver.ts index 8adec09..8bc263a 100644 --- a/src/drivers/ot-rcp-driver.ts +++ b/src/drivers/ot-rcp-driver.ts @@ -133,6 +133,9 @@ export class OTRCPDriver { onAPSSendTransportKeyNWK: async (address16, key, keySeqNum, destination64) => { await this.apsHandler.sendTransportKeyNWK(address16, key, keySeqNum, destination64); }, + onAPSSendStartKeyUpdateRequest: async (nwkDest16, nwkDest64, keyNegotiationProtocol, preSharedSecret) => { + await this.apsHandler.sendStartKeyUpdateRequest(nwkDest16, nwkDest64, keyNegotiationProtocol, preSharedSecret); + }, }; this.nwkHandler = new NWKHandler(this.context, this.macHandler, nwkCallbacks); diff --git a/src/zigbee-stack/aps-handler.ts b/src/zigbee-stack/aps-handler.ts index 16115ef..d87322c 100644 --- a/src/zigbee-stack/aps-handler.ts +++ b/src/zigbee-stack/aps-handler.ts @@ -8,7 +8,15 @@ import { type MACHeader, ZigbeeMACConsts, } from "../zigbee/mac.js"; -import { GlobalTlv, readZigbeeTlvs } from "../zigbee/tlvs.js"; +import { + GlobalTlv, + KeyNegotationProtocol, + KeyNegotationProtocolMask, + type PreSharedSecret, + PreSharedSecretMask, + readZigbeeTlvs, + writeZigbeeTlvFragmentationParameters, +} from "../zigbee/tlvs.js"; import { ZigbeeConsts, ZigbeeKeyType, type ZigbeeSecurityHeader, ZigbeeSecurityLevel } from "../zigbee/zigbee.js"; import { decodeZigbeeAPSFrameControl, @@ -1053,57 +1061,78 @@ export class APSHandler { ); if (apsHeader.profileId === ZigbeeConsts.ZDO_PROFILE_ID) { - if (apsHeader.clusterId === ZigbeeConsts.END_DEVICE_ANNOUNCE) { - let offset = 1; // skip seq num - const address16 = data.readUInt16LE(offset); - offset += 2; - const address64 = data.readBigUInt64LE(offset); - offset += 8; - const capabilities = data.readUInt8(offset); - offset += 1; - - const device = this.#context.deviceTable.get(address64); - - if (device === undefined) { - // unknown device, should have been added by `associate`, something's not right, ignore it - return; - } + switch (apsHeader.clusterId) { + case ZigbeeConsts.END_DEVICE_ANNOUNCE: { + let offset = 1; // skip seq num + const address16 = data.readUInt16LE(offset); + offset += 2; + const address64 = data.readBigUInt64LE(offset); + offset += 8; + const capabilities = data.readUInt8(offset); + offset += 1; + + const device = this.#context.deviceTable.get(address64); + + if (device === undefined) { + // unknown device, should have been added by `associate`, something's not right, ignore it + return; + } - const decodedCap = decodeMACCapabilities(capabilities); + const decodedCap = decodeMACCapabilities(capabilities); - if (device.address16 !== address16) { - this.#context.address16ToAddress64.delete(device.address16); - this.#context.address16ToAddress64.set(address16, address64); + if (device.address16 !== address16) { + // change of address + this.#context.address16ToAddress64.delete(device.address16); + this.#context.address16ToAddress64.set(address16, address64); - device.address16 = address16; - } + device.address16 = address16; + } - // just in case - device.capabilities = decodedCap; + // just in case + device.capabilities = decodedCap; - await this.#context.savePeriodicState(); + await this.#context.savePeriodicState(); - // TODO: ideally, this shouldn't trigger (prevents early interview process from app) until AFTER authorized=true - setImmediate(() => { - // if device is authorized, it means it completed the TC link key update, so, a rejoin - // TODO: could flip authorized to true before the announce and count as rejoin when it shouldn't - if (device.authorized) { - this.#callbacks.onDeviceRejoined(address16, address64, decodedCap); - } else { - this.#callbacks.onDeviceJoined(address16, address64, decodedCap); - } - }); - } else { - const isRequest = (apsHeader.clusterId! & 0x8000) === 0; + // TODO: ideally, this shouldn't trigger (prevents early interview process from app) until AFTER authorized=true + setImmediate(() => { + // if device is authorized, it means it completed the TC link key update, so, a rejoin + // TODO: could flip authorized to true before the announce and count as rejoin when it shouldn't + if (device.authorized) { + this.#callbacks.onDeviceRejoined(address16, address64, decodedCap); + } else { + this.#callbacks.onDeviceJoined(address16, address64, decodedCap); + } + }); - if (isRequest) { - if (this.isZDORequestForCoordinator(apsHeader.clusterId!, nwkHeader.destination16, nwkHeader.destination64, data)) { - await this.respondToCoordinatorZDORequest(data, apsHeader.clusterId!, nwkHeader.source16, nwkHeader.source64); + break; + } + case ZigbeeConsts.START_KEY_UPDATE_RESPONSE: { + // skip seq num + const status = data.readUInt8(1); + + if (status !== ZigbeeConsts.ZDO_SUCCESS) { + // failed security, cleanup + await this.#context.disassociate(nwkHeader.source16, nwkHeader.source64); } - // don't emit received ZDO requests + // handled internally, don't emit + return; + } + case undefined: { return; } + default: { + const isRequest = (apsHeader.clusterId & 0x8000) === 0; + + if (isRequest) { + if (this.isZDORequestForCoordinator(apsHeader.clusterId, nwkHeader.destination16, nwkHeader.destination64, data)) { + await this.respondToCoordinatorZDORequest(data, apsHeader.clusterId, nwkHeader.source16, nwkHeader.source64); + } + + // don't emit received ZDO requests + return; + } + } } } @@ -1592,15 +1621,27 @@ export class APSHandler { if (joinerTlv !== undefined) { // device is R23 - const fragmentationParametersTlv = joinerTlv.additionalTlvs[GlobalTlv.FRAGMENTATION_PARAMETERS]; const supportedKeyNegotiationMethodsTlv = joinerTlv.additionalTlvs[GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS]; - if (fragmentationParametersTlv !== undefined) { - // TODO + if (supportedKeyNegotiationMethodsTlv === undefined) { + if (status === ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_UNSECURED_JOIN) { + return; // invalid + } + } else { + // TODO: support other protocols + if (!(supportedKeyNegotiationMethodsTlv.keyNegotiationProtocolsBitmask & KeyNegotationProtocolMask.Z3)) { + return; // per spec, must always be supported + } + + if (!(supportedKeyNegotiationMethodsTlv.preSharedSecretsBitmask & PreSharedSecretMask.INSTALL_CODE_KEY)) { + return; // others currently not supported + } } - if (supportedKeyNegotiationMethodsTlv !== undefined) { - // TODO + const fragmentationParametersTlv = joinerTlv.additionalTlvs[GlobalTlv.FRAGMENTATION_PARAMETERS]; + + if (fragmentationParametersTlv === undefined) { + return; // invalid } } @@ -1621,56 +1662,70 @@ export class APSHandler { await this.#context.associate(device16, device64, undefined, false, true); this.#updateSourceRouteForChild(device16, nwkHeader.source16, nwkHeader.source64); - const tApsCmdPayload = Buffer.allocUnsafe(19 + ZigbeeAPSConsts.CMD_KEY_LENGTH); - let tunnelOffset = 0; - tunnelOffset = tApsCmdPayload.writeUInt8(ZigbeeAPSCommandId.TRANSPORT_KEY, tunnelOffset); - tunnelOffset = tApsCmdPayload.writeUInt8(ZigbeeAPSConsts.CMD_KEY_STANDARD_NWK, tunnelOffset); - tunnelOffset += this.#context.netParams.networkKey.copy(tApsCmdPayload, tunnelOffset); - tunnelOffset = tApsCmdPayload.writeUInt8(this.#context.netParams.networkKeySequenceNumber, tunnelOffset); - tunnelOffset = tApsCmdPayload.writeBigUInt64LE(device64, tunnelOffset); - tunnelOffset = tApsCmdPayload.writeBigUInt64LE(this.#context.netParams.eui64, tunnelOffset); // 0xFFFFFFFFFFFFFFFF in distributed network (no TC) - - const tApsCmdFrame = encodeZigbeeAPSFrame( - { - frameControl: { - frameType: ZigbeeAPSFrameType.CMD, - deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, - ackFormat: false, - security: true, - ackRequest: false, - extendedHeader: false, + if (this.#context.trustCenterPolicies.keyNegotiationProtocol === KeyNegotationProtocol.Z3) { + const tApsCmdPayload = Buffer.allocUnsafe(19 + ZigbeeAPSConsts.CMD_KEY_LENGTH); + let tunnelOffset = 0; + tunnelOffset = tApsCmdPayload.writeUInt8(ZigbeeAPSCommandId.TRANSPORT_KEY, tunnelOffset); + tunnelOffset = tApsCmdPayload.writeUInt8(ZigbeeAPSConsts.CMD_KEY_STANDARD_NWK, tunnelOffset); + tunnelOffset += this.#context.netParams.networkKey.copy(tApsCmdPayload, tunnelOffset); + tunnelOffset = tApsCmdPayload.writeUInt8(this.#context.netParams.networkKeySequenceNumber, tunnelOffset); + tunnelOffset = tApsCmdPayload.writeBigUInt64LE(device64, tunnelOffset); + tunnelOffset = tApsCmdPayload.writeBigUInt64LE(this.#context.netParams.eui64, tunnelOffset); // 0xFFFFFFFFFFFFFFFF in distributed network (no TC) + + const tApsCmdFrame = encodeZigbeeAPSFrame( + { + frameControl: { + frameType: ZigbeeAPSFrameType.CMD, + deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, + ackFormat: false, + security: true, + ackRequest: false, + extendedHeader: false, + }, + counter: this.nextCounter(), }, - counter: this.nextCounter(), - }, - tApsCmdPayload, - { - control: { - level: ZigbeeSecurityLevel.NONE, - keyId: ZigbeeKeyType.TRANSPORT, - nonce: true, - reqVerifiedFc: false, + tApsCmdPayload, + { + control: { + level: ZigbeeSecurityLevel.NONE, + keyId: ZigbeeKeyType.TRANSPORT, + nonce: true, + reqVerifiedFc: false, + }, + frameCounter: this.#context.nextTCKeyFrameCounter(), + source64: this.#context.netParams.eui64, + micLen: 4, }, - frameCounter: this.#context.nextTCKeyFrameCounter(), - source64: this.#context.netParams.eui64, - micLen: 4, - }, - undefined, // use pre-hashed this.context.netParams.tcKey, - ); - - await this.sendTunnel(nwkHeader.source16!, device64, tApsCmdFrame); - this.#context.markNetworkKeyTransported(device64); + undefined, // use pre-hashed this.context.netParams.tcKey, + ); + + await this.sendTunnel(nwkHeader.source16!, device64, tApsCmdFrame); + this.#context.markNetworkKeyTransported(device64); + } else { + await this.sendStartKeyUpdateRequest( + device16, + device64, + this.#context.trustCenterPolicies.keyNegotiationProtocol, + this.#context.trustCenterPolicies.preSharedSecret, + ); + } break; } case ZigbeeAPSUpdateDeviceStatus.STANDARD_DEVICE_TRUST_CENTER_REJOIN: { await this.#context.associate(device16, device64, undefined, false, false); - await this.sendTransportKeyNWK( - device16, - this.#context.netParams.networkKey, - this.#context.netParams.networkKeySequenceNumber, - device64, - ); - this.#context.markNetworkKeyTransported(device64); + + if (this.#context.trustCenterPolicies.keyNegotiationProtocol === KeyNegotationProtocol.Z3) { + await this.sendTransportKeyNWK( + device16, + this.#context.netParams.networkKey, + this.#context.netParams.networkKeySequenceNumber, + device64, + ); + this.#context.markNetworkKeyTransported(device64); + } else { + // not supported by current spec + } break; } @@ -2169,6 +2224,60 @@ export class APSHandler { // NOTE: sendRelayMessageUpstream DEVICE SCOPE: [unauthorized] routers (N/A), end devices (N/A) + /** + * 06-3474-23 #4.4.9.1 APSME-KEY-NEGOTIATION.request + * + * SPEC COMPLIANCE: + * - TODO + * + * DEVICE SCOPE: Trust Center + */ + public async sendStartKeyUpdateRequest( + nwkDest16: number, + nwkDest64: bigint, + keyNegotiationProtocol: KeyNegotationProtocol, + preSharedSecret: PreSharedSecret, + ): Promise { + const ackHeader: ZigbeeAPSHeader = { + frameControl: { + frameType: ZigbeeAPSFrameType.DATA, + deliveryMode: ZigbeeAPSDeliveryMode.UNICAST, + ackFormat: false, + security: false, + ackRequest: true, + extendedHeader: false, + }, + destEndpoint: ZigbeeConsts.ZDO_ENDPOINT, + clusterId: ZigbeeConsts.START_KEY_UPDATE_REQUEST, + profileId: ZigbeeConsts.ZDO_PROFILE_ID, + sourceEndpoint: ZigbeeConsts.ZDO_ENDPOINT, + counter: this.nextCounter(), + }; + const apsPayload = Buffer.allocUnsafe(20); + let offset = 0; + offset = apsPayload.writeUInt8(this.nextZDOSeqNum(), offset); + // local TLV: Selected Key Negotiation Method + offset = apsPayload.writeUInt8(0x00, offset); + offset = apsPayload.writeUInt8(9, offset); // per spec, actual data length is `length field + 1` + offset = apsPayload.writeUInt8(keyNegotiationProtocol, offset); + offset = apsPayload.writeUInt8(preSharedSecret, offset); + offset = apsPayload.writeBigUInt64LE(this.#context.netParams.eui64, offset); + writeZigbeeTlvFragmentationParameters(apsPayload, offset, { + nwkAddress: ZigbeeConsts.COORDINATOR_ADDRESS, + fragmentationOptions: 0b1, + maxIncomingTransferUnit: ZigbeeMACConsts.FRAME_MAX_SIZE, + }); + + const apsFrame = encodeZigbeeAPSFrame( + ackHeader, + apsPayload, + // undefined, + // undefined, + ); + + return await this.sendRelayMessageDownstream(nwkDest16, nwkDest64, nwkDest64, apsFrame); + } + // #endregion // #region ZDO Helpers diff --git a/src/zigbee-stack/nwk-handler.ts b/src/zigbee-stack/nwk-handler.ts index 6ea3fac..b769009 100644 --- a/src/zigbee-stack/nwk-handler.ts +++ b/src/zigbee-stack/nwk-handler.ts @@ -9,7 +9,14 @@ import { type MACHeader, ZigbeeMACConsts, } from "../zigbee/mac.js"; -import { GlobalTlv, GlobalTlvConsts, readZigbeeTlvs } from "../zigbee/tlvs.js"; +import { + GlobalTlv, + KeyNegotationProtocol, + KeyNegotationProtocolMask, + type PreSharedSecret, + PreSharedSecretMask, + readZigbeeTlvs, +} from "../zigbee/tlvs.js"; import { ZigbeeConsts, ZigbeeKeyType, type ZigbeeSecurityHeader, ZigbeeSecurityLevel } from "../zigbee/zigbee.js"; import { encodeZigbeeNWKFrame, @@ -34,6 +41,13 @@ const NS = "nwk-handler"; export interface NWKHandlerCallbacks { /** Send APS TRANSPORT_KEY for network key */ onAPSSendTransportKeyNWK: (destination16: number, networkKey: Buffer, keySequenceNumber: number, destination64: bigint) => Promise; + /** Send APS ZDO START_KEY_UPDATE_REQUEST */ + onAPSSendStartKeyUpdateRequest: ( + nwkDest16: number, + nwkDest64: bigint, + keyNegotiationProtocol: KeyNegotationProtocol, + preSharedSecret: PreSharedSecret, + ) => Promise; } /** The number of OctetDurations until a route discovery expires. */ @@ -1300,7 +1314,7 @@ export class NWKHandler { status = MACAssociationStatus.PAN_FULL; } else { status = ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT; - requiresTransportKey = !secured; + requiresTransportKey = !secured; // only if Trust Center Rejoin const neighbor = macHeader.source16 === nwkHeader.source16; await this.#context.associate(newAddress16, nwkHeader.source64, decodedCap, neighbor, false); @@ -1308,7 +1322,7 @@ export class NWKHandler { } else { newAddress16 = nwkHeader.source16; status = MACAssociationStatus.SUCCESS; - requiresTransportKey = !secured; + requiresTransportKey = !secured; // only if Trust Center Rejoin const neighbor = macHeader.source16 === nwkHeader.source16; await this.#context.associate(newAddress16, nwkHeader.source64, decodedCap, neighbor, false); @@ -1318,7 +1332,7 @@ export class NWKHandler { await this.sendRejoinResp(nwkHeader.source16, newAddress16, status); if (requiresTransportKey) { - // XXX: is this spec? + // XXX: use tunnel even if direct Coordinator<>rejoiner? await this.#callbacks.onAPSSendTransportKeyNWK( newAddress16, this.#context.netParams.networkKey, @@ -1878,8 +1892,9 @@ export class NWKHandler { const commissioningType = data.readUInt8(offset); offset += 1; const secured = nwkHeader.frameControl.security; + const initialJoin = commissioningType === ZigbeeNWKCommissioningType.INITIAL_JOIN; - if (secured && commissioningType === ZigbeeNWKCommissioningType.INITIAL_JOIN) { + if (secured && initialJoin) { return; // per spec, drop } @@ -1888,30 +1903,39 @@ export class NWKHandler { const decodedCap = decodeMACCapabilities(capabilities); const [globalTlvs] = readZigbeeTlvs(data, offset); const joinerTlv = globalTlvs[GlobalTlv.JOINER_ENCAPSULATION]; - let selectedKeyNegotiationMethod = GlobalTlvConsts.KEY_NEGOTATION_METHOD_STATIC; // fallback - if (joinerTlv !== undefined) { - // device is R23 - const fragmentationParametersTlv = joinerTlv.additionalTlvs[GlobalTlv.FRAGMENTATION_PARAMETERS]; - const supportedKeyNegotiationMethodsTlv = joinerTlv.additionalTlvs[GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS]; + if (joinerTlv === undefined) { + return; // invalid + } + + const supportedKeyNegotiationMethodsTlv = joinerTlv.additionalTlvs[GlobalTlv.SUPPORTED_KEY_NEGOTIATION_METHODS]; + const rejoin = commissioningType === ZigbeeNWKCommissioningType.REJOIN; - if (fragmentationParametersTlv !== undefined) { - // TODO + if (supportedKeyNegotiationMethodsTlv === undefined) { + if (!rejoin) { + return; // invalid + } + } else { + // TODO: support other protocols + if (!(supportedKeyNegotiationMethodsTlv.keyNegotiationProtocolsBitmask & KeyNegotationProtocolMask.Z3)) { + return; // per spec, must always be supported } - if (supportedKeyNegotiationMethodsTlv !== undefined) { - // TODO - const bitmask = supportedKeyNegotiationMethodsTlv.keyNegotiationProtocolsBitmask; + if (!(supportedKeyNegotiationMethodsTlv.preSharedSecretsBitmask & PreSharedSecretMask.INSTALL_CODE_KEY)) { + // XXX: spec? + await this.sendCommissioningResponse(nwkHeader.source16, 0xffff, MACAssociationStatus.PAN_ACCESS_DENIED); - // TODO: by order of "most security"? - if (bitmask & GlobalTlvConsts.KEY_NEGOTATION_METHOD_SHA256) { - selectedKeyNegotiationMethod = GlobalTlvConsts.KEY_NEGOTATION_METHOD_SHA256; - } else if (bitmask & GlobalTlvConsts.KEY_NEGOTATION_METHOD_MMO128) { - selectedKeyNegotiationMethod = GlobalTlvConsts.KEY_NEGOTATION_METHOD_MMO128; - } + return; // others currently not supported } } + // TODO: store in state? + const fragmentationParametersTlv = joinerTlv.additionalTlvs[GlobalTlv.FRAGMENTATION_PARAMETERS]; + + if (fragmentationParametersTlv === undefined) { + return; // invalid + } + logger.debug( () => `<=== NWK COMMISSIONING_REQUEST[macSrc=${macHeader.source16}:${macHeader.source64} nwkSrc=${nwkHeader.source16}:${nwkHeader.source64} sec=${secured} type=${commissioningType} cap=${capabilities}]`, @@ -1924,8 +1948,8 @@ export class NWKHandler { let status: MACAssociationStatus | number = MACAssociationStatus.PAN_ACCESS_DENIED; let requiresTransportKey = false; - if (commissioningType === ZigbeeNWKCommissioningType.INITIAL_JOIN) { - if (this.#context.trustCenterPolicies.allowJoins) { + if (initialJoin) { + if (this.#context.macAssociationPermit && this.#context.trustCenterPolicies.allowJoins) { if (this.#context.address16ToAddress64.has(nwkHeader.source16)) { // device address is conflicting, assign new one newAddress16 = this.#context.assignNetworkAddress(); @@ -1948,7 +1972,7 @@ export class NWKHandler { await this.#context.associate(newAddress16, nwkHeader.source64, decodedCap, neighbor, true); } } - } else if (commissioningType === ZigbeeNWKCommissioningType.REJOIN) { + } else if (rejoin) { const device = this.#context.deviceTable.get(nwkHeader.source64); if (device?.authorized) { @@ -1961,7 +1985,7 @@ export class NWKHandler { status = MACAssociationStatus.PAN_FULL; } else { status = ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT; - requiresTransportKey = !secured; + requiresTransportKey = !secured; // only if Trust Center Rejoin const neighbor = macHeader.source16 === nwkHeader.source16; await this.#context.associate(newAddress16, nwkHeader.source64, decodedCap, neighbor, false); @@ -1969,7 +1993,7 @@ export class NWKHandler { } else { newAddress16 = nwkHeader.source16; status = MACAssociationStatus.SUCCESS; - requiresTransportKey = !secured; + requiresTransportKey = !secured; // only if Trust Center Rejoin const neighbor = macHeader.source16 === nwkHeader.source16; await this.#context.associate(newAddress16, nwkHeader.source64, decodedCap, neighbor, false); @@ -1980,21 +2004,29 @@ export class NWKHandler { await this.sendCommissioningResponse(nwkHeader.source16, newAddress16, status); if (requiresTransportKey) { - // TODO: might need to be different if R23 or not - if (selectedKeyNegotiationMethod === GlobalTlvConsts.KEY_NEGOTATION_METHOD_STATIC) { - const dest64 = this.#context.address16ToAddress64.get(newAddress16); - - if (dest64 !== undefined && requiresTransportKey) { - await this.#callbacks.onAPSSendTransportKeyNWK( - newAddress16, - this.#context.netParams.networkKey, - this.#context.netParams.networkKeySequenceNumber, - dest64, - ); - this.#context.markNetworkKeyTransported(dest64); - } + if (this.#context.trustCenterPolicies.keyNegotiationProtocol === KeyNegotationProtocol.Z3) { + await this.#callbacks.onAPSSendTransportKeyNWK( + newAddress16, + this.#context.netParams.networkKey, + this.#context.netParams.networkKeySequenceNumber, + nwkHeader.source64, + ); + this.#context.markNetworkKeyTransported(nwkHeader.source64); } else { - // TODO START_KEY_UPDATE ZDO + if (rejoin) { + // At this time this Revision of the specification does not support negotiating a new link key during rejoin. + // Therefore, devices certified to this Revision SHALL not include the Supported Key Negotiation Methods Global TLV + // inside the Joiner Encapsulation TLV so it is clear to the Trust Center that the device does not support this behavior. + // Future revisions of this specification that support this would include this TLV as a clear sign the rejoining device supports this new functionality. + return; + } + + await this.#callbacks.onAPSSendStartKeyUpdateRequest( + newAddress16, + nwkHeader.source64, + this.#context.trustCenterPolicies.keyNegotiationProtocol, + this.#context.trustCenterPolicies.preSharedSecret, + ); } } } @@ -2029,7 +2061,7 @@ export class NWKHandler { false, // nwkSecurity ZigbeeConsts.COORDINATOR_ADDRESS, // nwkSource16 requestSource16, // nwkDest16 - this.#context.address16ToAddress64.get(requestSource16), // nwkDest64 + this.#context.address16ToAddress64.get(newAddress16), // nwkDest64 CONFIG_NWK_MAX_HOPS, // nwkRadius ); } diff --git a/src/zigbee-stack/stack-context.ts b/src/zigbee-stack/stack-context.ts index a552cbf..9a1e34a 100644 --- a/src/zigbee-stack/stack-context.ts +++ b/src/zigbee-stack/stack-context.ts @@ -1,6 +1,7 @@ import { readFile, rm, writeFile } from "node:fs/promises"; import { logger } from "../utils/logger.js"; import { decodeMACCapabilities, encodeMACCapabilities, type MACCapabilities, type MACHeader } from "../zigbee/mac.js"; +import { KeyNegotationProtocol, PreSharedSecret } from "../zigbee/tlvs.js"; import { aes128MmoHash, computeInstallCodeCRC, @@ -100,6 +101,10 @@ export type TrustCenterPolicies = { * A setting of FALSE means rejoins are only allowed with trust center link keys where the KeyAttributes of the apsDeviceKeyPairSet entry indicates VERIFIED_KEY. */ allowRejoinsWithWellKnownKey: boolean; + /** R23+ */ + keyNegotiationProtocol: KeyNegotationProtocol; + /** R23+ */ + preSharedSecret: PreSharedSecret; /** This value controls whether devices are allowed to request a Trust Center Link Key after they have joined the network. */ allowTCKeyRequest: TrustCenterKeyRequestPolicy; /** This policy indicates whether a node on the network that transmits a ZDO Mgmt_Permit_Join with a significance set to 1 is allowed to effect the local Trust Center’s policies. */ @@ -389,6 +394,10 @@ export class StackContext { allowJoins: false, installCode: InstallCodePolicy.NOT_REQUIRED, allowRejoinsWithWellKnownKey: true, + // TODO: support other protocols + keyNegotiationProtocol: KeyNegotationProtocol.Z3, + // TODO: support other secrets + preSharedSecret: PreSharedSecret.INSTALL_CODE_KEY, allowTCKeyRequest: TrustCenterKeyRequestPolicy.ALLOWED, networkKeyUpdatePeriod: 0, // disable networkKeyUpdateMethod: NetworkKeyUpdateMethod.BROADCAST, diff --git a/src/zigbee/tlvs.ts b/src/zigbee/tlvs.ts index def5072..3403f1a 100644 --- a/src/zigbee/tlvs.ts +++ b/src/zigbee/tlvs.ts @@ -1,12 +1,6 @@ import type { RequiredNonNullable } from "../utils/types.js"; import { ZigbeeConsts } from "./zigbee.js"; -export const enum GlobalTlvConsts { - KEY_NEGOTATION_METHOD_STATIC = 0b000, - KEY_NEGOTATION_METHOD_MMO128 = 0b010, - KEY_NEGOTATION_METHOD_SHA256 = 0b100, -} - export const enum GlobalTlv { /** minLen=2 */ MANUFACTURER_SPECIFIC = 64, @@ -33,6 +27,41 @@ export const enum GlobalTlv { // Reserved = 77-255 } +/** uint8 */ +export const enum KeyNegotationProtocol { + Z3 = 0x0, + MMO128 = 0x1, + SHA256 = 0x2, +} + +/** uint8 */ +export const enum KeyNegotationProtocolMask { + Z3 = 0b01, + MMO128 = 0b10, + SHA256 = 0b11, +} + +/** uint8 */ +export const enum PreSharedSecret { + SYMMETRIC_AUTHENTICATION_TOKEN = 0x00, + INSTALL_CODE_KEY = 0x01, + PASSCODE_KEY = 0x02, + BASIC_ACCESS_KEY = 0x03, + ADMINISTRATIVE_ACCESS_KEY = 0x04, + // 0x04-0xfe reserved + ANONYMOUS_WELL_KNOWN_SECRET = 0xff, +} + +/** uint8 */ +export const enum PreSharedSecretMask { + SYMMETRIC_AUTHENTICATION_TOKEN = 0b00001, + INSTALL_CODE_KEY = 0b00010, + PASSCODE_KEY = 0b00100, + BASIC_ACCESS_KEY = 0b01000, + ADMINISTRATIVE_ACCESS_KEY = 0b10000, + // others reserved +} + type GlobalTlvEncapsulated = { additionalTlvs: ZigbeeGlobalTlvs; additionalLocalTlvs: Map; @@ -406,6 +435,7 @@ export function readZigbeeTlvs(data: Buffer, offset: number, parent?: number): [ return [globalTlvs, localTlvs, offset]; } +/** Byte length: 12 */ export function writeZigbeeTlvSupportedKeyNegotiationMethods( data: Buffer, offset: number, @@ -420,6 +450,7 @@ export function writeZigbeeTlvSupportedKeyNegotiationMethods( return offset; } +/** Byte length: 7 */ export function writeZigbeeTlvFragmentationParameters( data: Buffer, offset: number, @@ -434,6 +465,7 @@ export function writeZigbeeTlvFragmentationParameters( return offset; } +/** Byte length: 4 */ export function writeZigbeeTlvRouterInformation(data: Buffer, offset: number, tlv: RequiredNonNullable): number { offset = data.writeUInt8(GlobalTlv.ROUTER_INFORMATION, offset); offset = data.writeUInt8(1, offset); // per spec, actual data length is `length field + 1` diff --git a/src/zigbee/zigbee.ts b/src/zigbee/zigbee.ts index bd5a9f1..d90a74c 100644 --- a/src/zigbee/zigbee.ts +++ b/src/zigbee/zigbee.ts @@ -27,6 +27,7 @@ export const enum ZigbeeConsts { //---- ZDO ZDO_ENDPOINT = 0x00, ZDO_PROFILE_ID = 0x0000, + ZDO_SUCCESS = 0x00, NETWORK_ADDRESS_REQUEST = 0x0000, IEEE_ADDRESS_REQUEST = 0x0001, NODE_DESCRIPTOR_REQUEST = 0x0002, @@ -37,6 +38,8 @@ export const enum ZigbeeConsts { LQI_TABLE_REQUEST = 0x0031, ROUTING_TABLE_REQUEST = 0x0032, NWK_UPDATE_REQUEST = 0x0038, + START_KEY_UPDATE_REQUEST = 0x0045, + START_KEY_UPDATE_RESPONSE = 0x8045, //---- Green Power GP_ENDPOINT = 0xf2, diff --git a/test/compliance/aps.test.ts b/test/compliance/aps.test.ts index 2515f0d..0d1b71c 100644 --- a/test/compliance/aps.test.ts +++ b/test/compliance/aps.test.ts @@ -107,6 +107,7 @@ describe("Zigbee 4.0 Application Support (APS) Layer Compliance", () => { }; mockNWKHandlerCallbacks = { onAPSSendTransportKeyNWK: vi.fn(), + onAPSSendStartKeyUpdateRequest: vi.fn(), }; mockNWKGPHandlerCallbacks = { onGPFrame: vi.fn(), diff --git a/test/compliance/bdb.test.ts b/test/compliance/bdb.test.ts index 5e1251c..cbfb603 100644 --- a/test/compliance/bdb.test.ts +++ b/test/compliance/bdb.test.ts @@ -25,6 +25,7 @@ import { MACFrameType, type MACHeader, } from "../../src/zigbee/mac.js"; +import { GlobalTlv, writeZigbeeTlvFragmentationParameters, writeZigbeeTlvSupportedKeyNegotiationMethods } from "../../src/zigbee/tlvs.js"; import { makeKeyedHashByType, registerDefaultHashedKeys, ZigbeeConsts, ZigbeeKeyType } from "../../src/zigbee/zigbee.js"; import { ZigbeeAPSConsts, ZigbeeAPSDeliveryMode, ZigbeeAPSFrameType, type ZigbeeAPSHeader } from "../../src/zigbee/zigbee-aps.js"; import { @@ -94,6 +95,7 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { }; mockNWKHandlerCallbacks = { onAPSSendTransportKeyNWK: vi.fn(), + onAPSSendStartKeyUpdateRequest: vi.fn(), }; mockNWKGPHandlerCallbacks = { onGPFrame: vi.fn(), @@ -791,7 +793,23 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { allocateAddress: true, }; const { mac, nwk } = buildCommissioningHeaders(device64, device16); - const payload = Buffer.from([ZigbeeNWKCommandId.COMMISSIONING_REQUEST, 0x00, encodeMACCapabilities(capabilities)]); + const payload = Buffer.allocUnsafe(3 + 21); + let offset = 0; + offset = payload.writeUInt8(ZigbeeNWKCommandId.COMMISSIONING_REQUEST, offset); + offset = payload.writeUInt8(0x00, offset); + offset = payload.writeUInt8(encodeMACCapabilities(capabilities), offset); + offset = payload.writeUInt8(GlobalTlv.JOINER_ENCAPSULATION, offset); + offset = payload.writeUInt8(18, offset); + offset = writeZigbeeTlvSupportedKeyNegotiationMethods(payload, offset, { + keyNegotiationProtocolsBitmask: 0x07, + preSharedSecretsBitmask: 0x06, + sourceDeviceEui64: device64, + }); + offset = writeZigbeeTlvFragmentationParameters(payload, offset, { + nwkAddress: device16, + fragmentationOptions: 0x1, + maxIncomingTransferUnit: 0x52, + }); const frames: Buffer[] = []; mockMACHandlerCallbacks.onSendFrame = vi.fn((frame) => { frames.push(Buffer.from(frame)); @@ -846,7 +864,23 @@ describe("Zigbee 4.0 Device Behavior Compliance", () => { allocateAddress: true, }; const { mac, nwk } = buildCommissioningHeaders(device64, device16); - const payload = Buffer.from([ZigbeeNWKCommandId.COMMISSIONING_REQUEST, 0x00, encodeMACCapabilities(capabilities)]); + const payload = Buffer.allocUnsafe(3 + 21); + let offset = 0; + offset = payload.writeUInt8(ZigbeeNWKCommandId.COMMISSIONING_REQUEST, offset); + offset = payload.writeUInt8(0x00, offset); + offset = payload.writeUInt8(encodeMACCapabilities(capabilities), offset); + offset = payload.writeUInt8(GlobalTlv.JOINER_ENCAPSULATION, offset); + offset = payload.writeUInt8(18, offset); + offset = writeZigbeeTlvSupportedKeyNegotiationMethods(payload, offset, { + keyNegotiationProtocolsBitmask: 0x07, + preSharedSecretsBitmask: 0x06, + sourceDeviceEui64: device64, + }); + offset = writeZigbeeTlvFragmentationParameters(payload, offset, { + nwkAddress: device16, + fragmentationOptions: 0x1, + maxIncomingTransferUnit: 0x52, + }); const transportSpy = vi .spyOn(mockNWKHandlerCallbacks, "onAPSSendTransportKeyNWK") diff --git a/test/compliance/integration.test.ts b/test/compliance/integration.test.ts index 51927ee..bfd1314 100644 --- a/test/compliance/integration.test.ts +++ b/test/compliance/integration.test.ts @@ -120,6 +120,7 @@ describe("Integration and End-to-End Compliance", () => { }; mockNWKHandlerCallbacks = { onAPSSendTransportKeyNWK: vi.fn(), + onAPSSendStartKeyUpdateRequest: vi.fn(), }; mockNWKGPHandlerCallbacks = { onGPFrame: vi.fn(), diff --git a/test/compliance/mac.test.ts b/test/compliance/mac.test.ts index 4d79fb6..40d32ea 100644 --- a/test/compliance/mac.test.ts +++ b/test/compliance/mac.test.ts @@ -98,6 +98,7 @@ describe("IEEE 802.15.4-2020 MAC Layer Compliance", () => { }; mockNWKHandlerCallbacks = { onAPSSendTransportKeyNWK: vi.fn(), + onAPSSendStartKeyUpdateRequest: vi.fn(), }; mockNWKGPHandlerCallbacks = { onGPFrame: vi.fn(), diff --git a/test/compliance/nwk-gp.test.ts b/test/compliance/nwk-gp.test.ts index f250e5a..229d2b5 100644 --- a/test/compliance/nwk-gp.test.ts +++ b/test/compliance/nwk-gp.test.ts @@ -85,6 +85,7 @@ describe("Zigbee 4.0 Green Power (NWK GP) Compliance", () => { }; mockNWKHandlerCallbacks = { onAPSSendTransportKeyNWK: vi.fn(), + onAPSSendStartKeyUpdateRequest: vi.fn(), }; mockNWKGPHandlerCallbacks = { onGPFrame: vi.fn(), diff --git a/test/compliance/nwk.test.ts b/test/compliance/nwk.test.ts index 631b60c..de57851 100644 --- a/test/compliance/nwk.test.ts +++ b/test/compliance/nwk.test.ts @@ -102,6 +102,7 @@ describe("Zigbee 4.0 Network Layer (NWK) Compliance", () => { }; mockNWKHandlerCallbacks = { onAPSSendTransportKeyNWK: vi.fn(), + onAPSSendStartKeyUpdateRequest: vi.fn(), }; mockNWKGPHandlerCallbacks = { onGPFrame: vi.fn(), diff --git a/test/compliance/security.test.ts b/test/compliance/security.test.ts index 3b68ecf..7e314c0 100644 --- a/test/compliance/security.test.ts +++ b/test/compliance/security.test.ts @@ -123,6 +123,7 @@ describe("Zigbee 4.0 Security Compliance", () => { }; mockNWKHandlerCallbacks = { onAPSSendTransportKeyNWK: vi.fn(), + onAPSSendStartKeyUpdateRequest: vi.fn(), }; mockNWKGPHandlerCallbacks = { onGPFrame: vi.fn(), diff --git a/test/drivers/ot-rcp-driver.test.ts b/test/drivers/ot-rcp-driver.test.ts index a279aaf..5000fc2 100644 --- a/test/drivers/ot-rcp-driver.test.ts +++ b/test/drivers/ot-rcp-driver.test.ts @@ -32,6 +32,7 @@ import { type MACHeader, ZigbeeMACConsts, } from "../../src/zigbee/mac.js"; +import { GlobalTlv, writeZigbeeTlvFragmentationParameters, writeZigbeeTlvSupportedKeyNegotiationMethods } from "../../src/zigbee/tlvs.js"; import { ZigbeeConsts } from "../../src/zigbee/zigbee.js"; import { decodeZigbeeAPSFrameControl, @@ -979,7 +980,22 @@ describe("OT RCP Driver", () => { driver.context.allowJoins(0xfe, true); - const data = Buffer.from([0x00, 0x8e]); + const data = Buffer.allocUnsafe(2 + 21); + let offset = 0; + offset = data.writeUInt8(0x00, offset); + offset = data.writeUInt8(0x8e, offset); + offset = data.writeUInt8(GlobalTlv.JOINER_ENCAPSULATION, offset); + offset = data.writeUInt8(18, offset); + offset = writeZigbeeTlvSupportedKeyNegotiationMethods(data, offset, { + keyNegotiationProtocolsBitmask: 0x07, + preSharedSecretsBitmask: 0x06, + sourceDeviceEui64: destination64, + }); + offset = writeZigbeeTlvFragmentationParameters(data, offset, { + nwkAddress: destination16, + fragmentationOptions: 0x1, + maxIncomingTransferUnit: 0x52, + }); const macHeader = { source16: destination16, source64: destination64, diff --git a/test/zigbee-stack/aps-handler.test.ts b/test/zigbee-stack/aps-handler.test.ts index 2466490..f635255 100644 --- a/test/zigbee-stack/aps-handler.test.ts +++ b/test/zigbee-stack/aps-handler.test.ts @@ -94,7 +94,8 @@ describe("APS Handler", () => { vi.spyOn(mockMACHandler, "sendFrameDirect"); mockNWKCallbacks = { - onAPSSendTransportKeyNWK: vi.fn(async () => {}), + onAPSSendTransportKeyNWK: vi.fn(), + onAPSSendStartKeyUpdateRequest: vi.fn(), }; mockNWKHandler = new NWKHandler(mockContext, mockMACHandler, mockNWKCallbacks); diff --git a/test/zigbee-stack/nwk-handler.test.ts b/test/zigbee-stack/nwk-handler.test.ts index d258df5..7d3023d 100644 --- a/test/zigbee-stack/nwk-handler.test.ts +++ b/test/zigbee-stack/nwk-handler.test.ts @@ -79,7 +79,8 @@ describe("NWK Handler", () => { vi.spyOn(mockMACHandler, "sendFrameDirect"); mockCallbacks = { - onAPSSendTransportKeyNWK: vi.fn(async () => {}), + onAPSSendTransportKeyNWK: vi.fn(), + onAPSSendStartKeyUpdateRequest: vi.fn(), }; nwkHandler = new NWKHandler(mockContext, mockMACHandler, mockCallbacks); From b2e8080bf4b69090fd2351506e18805bb0c90d8b Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:44:42 +0100 Subject: [PATCH 9/9] fix: minor cleanup --- src/dev/z2mdata-to-zohsave.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/dev/z2mdata-to-zohsave.ts b/src/dev/z2mdata-to-zohsave.ts index f84e258..d6ded37 100644 --- a/src/dev/z2mdata-to-zohsave.ts +++ b/src/dev/z2mdata-to-zohsave.ts @@ -219,8 +219,9 @@ async function convert(dataPath: string): Promise { authorized: device.interviewState === InterviewState.SUCCESSFUL, // add support for not knowing this in driver (re-evaluation) neighbor: backupDevice?.is_child !== true, - lastTransportedNetworkKeySeq: undefined, + lastTransportedNetworkKeySeq: networkKeySequenceNumber, recentLQAs: [], + lastReceivedRssi: undefined, incomingNWKFrameCounter: undefined, endDeviceTimeout: undefined, linkStatusMisses: 0,