UNPKG

zigbee-on-host

Version:

Zigbee stack designed to run on a host and communicate with a radio co-processor (RCP)

495 lines 24.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.MACHandler = void 0; const logger_js_1 = require("../utils/logger.js"); const mac_js_1 = require("../zigbee/mac.js"); const NS = "mac-handler"; /** * MAC Handler - IEEE 802.15.4 MAC Layer Protocol Operations * * Responsibilities: * - MAC frame transmission (direct and indirect) * - MAC command processing (ASSOC_REQ, ASSOC_RSP, BEACON_REQ, DATA_RQ) * - Association/reassociation handling * - Pending association management * - Indirect transmission queue management */ class MACHandler { #context; #callbacks; /** Emit frames flag (for debugging) */ #emitFrames; /** Code used in Error `cause` when sending throws because of MAC "no ACK" */ #noACKCode; // Private counters (start at 0, first call returns 1) #seqNum = 0; constructor(context, callbacks, noACKCode, emitFrames = false) { this.#context = context; this.#callbacks = callbacks; this.#emitFrames = emitFrames; this.#noACKCode = noACKCode; } // #region Getters/Setters get emitFrames() { return this.#emitFrames; } // #endregion async start() { } stop() { } /** * Get next MAC sequence number. * HOT PATH: Optimized counter increment * @returns Incremented MAC sequence number (wraps at 255) */ /* @__INLINE__ */ nextSeqNum() { this.#seqNum = (this.#seqNum + 1) & 0xff; return this.#seqNum; } /** * Send 802.15.4 MAC frame without checking for need to use indirect transmission. * * SPEC COMPLIANCE NOTES (IEEE 802.15.4-2015 #6.7.1 / #6.7.4): * - ✅ Transmits MAC payloads via MLME-DATA.request semantics with caller-provided sequence number * - ✅ Clears macNoACKs table on successful unicast (spec #6.7.4.3 requires reset after acknowledged delivery) * - ✅ Marks routes successful on unicast delivery to keep NWK path metrics aligned with MAC status * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) * * @param seqNum MAC sequence number * @param payload MAC frame payload * @param dest16 Destination 16-bit address * @param dest64 Destination 64-bit address * @returns True if success sending */ async sendFrameDirect(seqNum, payload, dest16, dest64) { if (dest16 === undefined && dest64 !== undefined) { dest16 = this.#context.deviceTable.get(dest64)?.address16; } try { logger_js_1.logger.debug(() => `===> MAC[seqNum=${seqNum} dst=${dest16}:${dest64}]`, NS); await this.#callbacks.onSendFrame(payload); if (this.#emitFrames) { setImmediate(() => { this.#callbacks.onFrame(payload); }); } if (dest16 !== undefined) { this.#context.macNoACKs.delete(dest16); this.#callbacks.onMarkRouteSuccess(dest16); } return true; } catch (error) { logger_js_1.logger.debug(() => `=x=> MAC[seqNum=${seqNum} dst=${dest16}:${dest64}] ${error.message}`, NS); if (error.cause === this.#noACKCode && dest16 !== undefined) { this.#context.macNoACKs.set(dest16, (this.#context.macNoACKs.get(dest16) ?? 0) + 1); this.#callbacks.onMarkRouteFailure(dest16); } return false; } } /** * Send 802.15.4 MAC frame. * Checks if indirect transmission is needed for devices with rxOnWhenIdle=false. * * SPEC COMPLIANCE NOTES (IEEE 802.15.4-2015 #6.7.3 / #6.3.4): * - ✅ Detects non-RX-on-when-idle children and queues frames for indirect transmission (spec #6.7.3.1) * - ✅ Uses MLME data queue semantics (first-in-first-out) when storing in indirectTransmissions * - ✅ Falls back to direct transmission when destination capabilities unknown, satisfying spec SHALL clauses * - ⚠️ Queue pruning relies on DATA_REQUEST processing (see processDataReq) to enforce macTransactionPersistenceTime * - ⚠️ Does not expose queue depth upper bound; relies on higher layers to avoid overflow * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) * * @param seqNum MAC sequence number * @param payload MAC frame payload * @param dest16 Destination 16-bit address * @param dest64 Destination 64-bit address * @returns True if success sending, undefined if set for indirect transmission */ async sendFrame(seqNum, payload, dest16, dest64) { if (dest16 !== undefined || dest64 !== undefined) { if (dest64 === undefined && dest16 !== undefined) { dest64 = this.#context.address16ToAddress64.get(dest16); } if (dest64 !== undefined) { const addrTXs = this.#context.indirectTransmissions.get(dest64); if (addrTXs) { addrTXs.push({ sendFrame: this.sendFrameDirect.bind(this, seqNum, payload, dest16, dest64), timestamp: Date.now(), }); logger_js_1.logger.debug(() => `=|=> MAC[seqNum=${seqNum} dst=${dest16}:${dest64}] set for indirect transmission (count=${addrTXs.length})`, NS); return; // done } } } // just send the packet when: // - RX on when idle // - can't determine radio state // - no dest info return await this.sendFrameDirect(seqNum, payload, dest16, dest64); } // #region Commands /** * Send 802.15.4 MAC command * * SPEC COMPLIANCE NOTES (IEEE 802.15.4-2015 #6.3 (MAC command frames)): * - ✅ Constructs command frame with frameType=CMD and securityDisabled per caller intent (spec #6.3.1) * - ✅ Selects source addressing mode based on extSource flag, aligning with ASSOC_RSP requirements * - ✅ Applies PAN ID compression rules for coordinator origin (spec #5.2.1.11) * - ✅ Delegates security and payload composition to caller, keeping command encoder generic * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) * * @param cmdId MAC command ID * @param dest16 Destination 16-bit address * @param dest64 Destination 64-bit address * @param extSource Use extended source address * @param payload Command payload * @returns True if success sending */ async sendCommand(cmdId, dest16, dest64, extSource, payload) { const macSeqNum = this.nextSeqNum(); logger_js_1.logger.debug(() => `===> MAC CMD[seqNum=${macSeqNum} cmdId=${cmdId} dst=${dest16}:${dest64} extSrc=${extSource}]`, NS); const macFrame = (0, mac_js_1.encodeMACFrame)({ frameControl: { frameType: 3 /* MACFrameType.CMD */, securityEnabled: false, framePending: false, ackRequest: dest16 !== 65535 /* ZigbeeMACConsts.BCAST_ADDR */, panIdCompression: true, seqNumSuppress: false, iePresent: false, destAddrMode: dest64 !== undefined ? 3 /* MACFrameAddressMode.EXT */ : 2 /* MACFrameAddressMode.SHORT */, frameVersion: 0 /* MACFrameVersion.V2003 */, sourceAddrMode: extSource ? 3 /* MACFrameAddressMode.EXT */ : 2 /* MACFrameAddressMode.SHORT */, }, sequenceNumber: macSeqNum, destinationPANId: this.#context.netParams.panId, destination16: dest16, destination64: dest64, source16: 0 /* ZigbeeConsts.COORDINATOR_ADDRESS */, source64: this.#context.netParams.eui64, commandId: cmdId, fcs: 0, }, payload); return await this.sendFrameDirect(macSeqNum, macFrame, dest16, dest64); } /** * Process 802.15.4 MAC command. * * SPEC COMPLIANCE NOTES (IEEE 802.15.4-2015 #6.3): * - ✅ Dispatches supported command identifiers (ASSOC_REQ/RSP, BEACON_REQ, DATA_REQ, DISASSOC_NOTIFY) * - ✅ Rejects unsupported commands with error log, mirroring spec requirement to ignore unknown MAC commands * - ✅ Preserves payload ordering by passing offset between handlers * - ⚠️ TODO: Implement COORD_REALIGN, ORPHAN_NOTIFY, PANID_CONFLICT handling per spec Annex B * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) * * @param data Command payload (without MAC header) * @param macHeader Decoded MAC header for context */ async processCommand(data, macHeader) { let offset = 0; switch (macHeader.commandId) { case 1 /* MACCommandId.ASSOC_REQ */: { offset = await this.processAssocReq(data, offset, macHeader); break; } case 2 /* MACCommandId.ASSOC_RSP */: { offset = this.processAssocRsp(data, offset, macHeader); break; } case 7 /* MACCommandId.BEACON_REQ */: { offset = await this.processBeaconReq(data, offset, macHeader); break; } case 4 /* MACCommandId.DATA_RQ */: { offset = await this.processDataReq(data, offset, macHeader); break; } case 3 /* MACCommandId.DISASSOC_NOTIFY */: { offset = await this.processDisassocNotify(data, offset, macHeader); break; } // TODO: other cases? // PANID_CONFLICT // ORPHAN_NOTIFY // COORD_REALIGN // GTS_REQ default: { logger_js_1.logger.error(`<=x= MAC CMD[cmdId=${macHeader.commandId} macSrc=${macHeader.source16}:${macHeader.source64}] Unsupported`, NS); return; } } // excess data in packet // if (offset < data.byteLength) { // logger.debug(() => `<=== MAC CMD contained more data: ${data.toString('hex')}`, NS); // } } /** * 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 * 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 */ async processAssocReq(data, offset, macHeader) { const capabilities = data.readUInt8(offset); offset += 1; logger_js_1.logger.debug(() => `<=== MAC ASSOC_REQ[macSrc=${macHeader.source16}:${macHeader.source64} cap=${capabilities}]`, NS); if (macHeader.source64 === undefined) { logger_js_1.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 = (0, mac_js_1.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 === mac_js_1.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(), }); } return offset; } /** * Process 802.15.4 MAC association response. * * SPEC COMPLIANCE NOTES (IEEE 802.15.4-2015 #6.3.2.4): * - ✅ Extracts short address and status per Table 6-4 * - ✅ Leaves further handling to higher layers (coordinator ignores downstream response) * - ⚠️ No validation of pending association map since coordinator is responder (not requester) * DEVICE SCOPE: Routers (N/A), end devices (N/A) * * @param data Command data * @param offset Current offset in data * @param macHeader MAC header * @returns New offset after processing */ processAssocRsp(data, offset, macHeader) { const address = data.readUInt16LE(offset); offset += 2; const status = data.readUInt8(offset); offset += 1; logger_js_1.logger.debug(() => `<=== MAC ASSOC_RSP[macSrc=${macHeader.source16}:${macHeader.source64} addr16=${address} status=${mac_js_1.MACAssociationStatus[status]}]`, NS); return offset; } /** * Process disassociation motification * * SPEC COMPLIANCE NOTES (IEEE 802.15.4-2015 #6.4.3.3): * - ✅ Handles both coordinator- and device-initiated reasons * - ✅ Removes device state through StackContext.disassociate * - ⚠️ Does not emit confirmation back to child (not required for coordinator role) * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) */ async processDisassocNotify(data, offset, macHeader) { const reason = data.readUInt8(offset); offset += 1; logger_js_1.logger.debug(() => `<=== MAC DISASSOC_NOTIFY[macSrc=${macHeader.source16}:${macHeader.source64} reason=${reason}]`, NS); if (reason === 1 /* MACDisassociationReason.COORDINATOR_INITIATED */ || reason === 2 /* MACDisassociationReason.DEVICE_INITIATED */) { const source16 = macHeader.source16 ?? (macHeader.source64 !== undefined ? this.#context.deviceTable.get(macHeader.source64)?.address16 : undefined); await this.#context.disassociate(source16, macHeader.source64); } return offset; } /** * Send 802.15.4 MAC association response * * SPEC COMPLIANCE NOTES (IEEE 802.15.4-2015 #6.3.2.5): * - ✅ Formats payload with short address + status per Table 6-5 * - ✅ Uses extended source address (extSource=true) as mandated for coordinators * - ✅ 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 */ async sendAssocRsp(dest64, newAddress16, status) { logger_js_1.logger.debug(() => `===> MAC ASSOC_RSP[dst64=${dest64} newAddr16=${newAddress16} status=${status}]`, NS); const finalPayload = Buffer.alloc(3); let offset = 0; offset = finalPayload.writeUInt16LE(newAddress16, offset); offset = finalPayload.writeUInt8(status, offset); return await this.sendCommand(2 /* MACCommandId.ASSOC_RSP */, undefined, // dest16 dest64, // dest64 true, // sourceExt finalPayload); } /** * Process 802.15.4 MAC beacon request. * * SPEC COMPLIANCE NOTES (IEEE 802.15.4-2015 #5.3.3): * - ✅ Responds with BEACON frame as required * - ✅ Uses frameType=BEACON correctly * - ✅ Sets securityEnabled=false (beacons typically unsecured) * - ✅ Sets framePending=false (beacons never have pending data) * - ✅ Sets ackRequest=false per spec (beacons are not acknowledged) * - ✅ Sets panIdCompression=false (source PAN ID must be present) * - ✅ Uses destAddrMode=NONE (beacons have no destination) * - ✅ Uses sourceAddrMode=SHORT with coordinator address * - ✅ SUPERFRAME spec values: * - beaconOrder=0x0f: Non-beacon enabled PAN ✅ (correct for Zigbee PRO) * - superframeOrder=0x0f: Matches beaconOrder ✅ * - finalCAPSlot=0x0f: Comment says "XXX: value from sniff, matches above" * * Should be calculated based on GTS allocation per spec * * 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 * - ✅ Sets gtsInfo.permit=false (no GTS support - typical for Zigbee) * - ✅ Empty pendAddr (no pending address fields) * - ✅ Zigbee Beacon Payload: * - protocolId=ZIGBEE_BEACON_PROTOCOL_ID (0x00) ✅ * - profile=0x2 (Zigbee PRO) ✅ * - version=VERSION_2007 ✅ * - routerCapacity=true ✅ * - deviceDepth=0 (coordinator) ✅ * - endDeviceCapacity=true ✅ * - extendedPANId matches context ✅ * - txOffset=0xffffff: Comment says "XXX: value from sniffed frames" * * 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 */ async processBeaconReq(_data, offset, _macHeader) { logger_js_1.logger.debug(() => "<=== MAC BEACON_REQ[]", NS); const macSeqNum = this.nextSeqNum(); const macFrame = (0, mac_js_1.encodeMACFrame)({ frameControl: { frameType: 0 /* MACFrameType.BEACON */, securityEnabled: false, framePending: false, ackRequest: false, panIdCompression: false, seqNumSuppress: false, iePresent: false, destAddrMode: 0 /* MACFrameAddressMode.NONE */, frameVersion: 0 /* MACFrameVersion.V2003 */, sourceAddrMode: 2 /* MACFrameAddressMode.SHORT */, }, sequenceNumber: macSeqNum, sourcePANId: this.#context.netParams.panId, source16: 0 /* ZigbeeConsts.COORDINATOR_ADDRESS */, superframeSpec: { beaconOrder: 0x0f, // value from spec superframeOrder: 0x0f, // value from spec finalCAPSlot: 0x0f, batteryExtension: false, panCoordinator: true, associationPermit: this.#context.associationPermit, }, gtsInfo: { permit: false }, pendAddr: {}, fcs: 0, }, (0, mac_js_1.encodeMACZigbeeBeacon)({ protocolId: 0 /* ZigbeeMACConsts.ZIGBEE_BEACON_PROTOCOL_ID */, profile: 0x2, // Zigbee PRO version: 2 /* 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, })); logger_js_1.logger.debug(() => `===> MAC BEACON[seqNum=${macSeqNum}]`, NS); await this.sendFrame(macSeqNum, macFrame, undefined, undefined); return offset; } /** * Process 802.15.4 MAC data request. * Used by indirect transmission devices to retrieve information from parent. * * SPEC COMPLIANCE NOTES (IEEE 802.15.4-2015 #6.3.4): * - ✅ Handles both source64 and source16 addressing * - ✅ Checks pending associations first (higher priority) * - ✅ Validates timestamp against MAC_INDIRECT_TRANSMISSION_TIMEOUT * - ✅ Deletes pending association after processing (prevents stale entries) * - ✅ Handles indirect transmissions from indirectTransmissions map * - ✅ Uses shift() to get FIFO ordering (oldest frame first) * - ✅ SPEC COMPLIANCE: Timestamp validation * - Checks (timestamp + timeout > Date.now()) ✅ * - Correctly expires old transmissions ✅ * - Iterates through queue to find non-expired frame ✅ * - ⚠️ POTENTIAL ISSUE: No limit on queue length * - Could accumulate many expired frames before cleanup * - Should consider periodic cleanup or max queue size * - ✅ Calls sendFrame() which will send with appropriate MAC params * - ✅ No ACK for DATA_RQ itself (command will be ACKed at MAC layer if requested) * * Per spec #6.3.4: "Upon receipt of data request, coordinator checks if data pending. * 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 */ async processDataReq(_data, offset, macHeader) { logger_js_1.logger.debug(() => `<=== MAC DATA_RQ[macSrc=${macHeader.source16}:${macHeader.source64}]`, NS); let address64 = macHeader.source64; if (address64 === undefined && macHeader.source16 !== undefined) { address64 = this.#context.address16ToAddress64.get(macHeader.source16); } if (address64 !== undefined) { const pendingAssoc = this.#context.pendingAssociations.get(address64); if (pendingAssoc) { if (pendingAssoc.timestamp + 7680 /* ZigbeeConsts.MAC_INDIRECT_TRANSMISSION_TIMEOUT */ > Date.now()) { await pendingAssoc.sendResp(); } // always delete, ensures no stale this.#context.pendingAssociations.delete(address64); } else { const addrTXs = this.#context.indirectTransmissions.get(address64); if (addrTXs !== undefined) { let tx = addrTXs.shift(); // deal with expired tx by looking for first that isn't do { if (tx !== undefined && tx.timestamp + 7680 /* ZigbeeConsts.MAC_INDIRECT_TRANSMISSION_TIMEOUT */ > Date.now()) { await tx.sendFrame(); break; } tx = addrTXs.shift(); } while (tx !== undefined); } } } return offset; } } exports.MACHandler = MACHandler; //# sourceMappingURL=mac-handler.js.map