ember-zli
Version:
Interact with EmberZNet-based adapters using zigbee-herdsman 'ember' driver
1,011 lines (1,010 loc) • 55.2 kB
JavaScript
import { randomBytes } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { checkbox, confirm, input, select } from "@inquirer/prompts";
import { Command } from "@oclif/core";
import { Presets, SingleBar } from "cli-progress";
import { ZSpec } from "zigbee-herdsman";
import { EmberTokensManager } from "zigbee-herdsman/dist/adapter/ember/adapter/tokensManager.js";
import { EmberExtendedSecurityBitmask, EmberInitialSecurityBitmask, EmberJoinMethod, EmberLibraryId, EmberNodeType, EzspNetworkScanType, SecManKeyType, SLStatus, } from "zigbee-herdsman/dist/adapter/ember/enums.js";
import { EMBER_AES_HASH_BLOCK_SIZE, EMBER_ENCRYPTION_KEY_SIZE } from "zigbee-herdsman/dist/adapter/ember/ezsp/consts.js";
import { EzspConfigId, EzspDecisionBitmask, EzspDecisionId, EzspMfgTokenId, EzspPolicyId } from "zigbee-herdsman/dist/adapter/ember/ezsp/enums.js";
import { initSecurityManagerContext } from "zigbee-herdsman/dist/adapter/ember/utils/initters.js";
import { toUnifiedBackup } from "zigbee-herdsman/dist/utils/backup.js";
import { eui64LEBufferToHex } from "zigbee-herdsman/dist/zspec/utils.js";
import { DEFAULT_CONFIGURATION_YAML_PATH, DEFAULT_NETWORK_BACKUP_PATH, DEFAULT_STACK_CONFIG_PATH, DEFAULT_TOKENS_BACKUP_PATH, logger, } from "../../index.js";
import { CREATOR_STACK_RESTORED_EUI64, TOUCHLINK_CHANNELS } from "../../utils/consts.js";
import { emberFullVersion, emberNetworkInit, emberStart, emberStop, getKeyStructBitmask, getLibraryStatus, waitForStackStatus, } from "../../utils/ember.js";
import { NVM3ObjectKey } from "../../utils/enums.js";
import { getPortConf } from "../../utils/port.js";
import { browseToFile, getBackupFromFile, toHex } from "../../utils/utils.js";
var StackMenu;
(function (StackMenu) {
StackMenu[StackMenu["STACK_INFO"] = 0] = "STACK_INFO";
StackMenu[StackMenu["STACK_CONFIG"] = 1] = "STACK_CONFIG";
StackMenu[StackMenu["NETWORK_INFO"] = 10] = "NETWORK_INFO";
StackMenu[StackMenu["NETWORK_SCAN"] = 11] = "NETWORK_SCAN";
StackMenu[StackMenu["NETWORK_BACKUP"] = 12] = "NETWORK_BACKUP";
StackMenu[StackMenu["NETWORK_RESTORE"] = 13] = "NETWORK_RESTORE";
StackMenu[StackMenu["NETWORK_LEAVE"] = 14] = "NETWORK_LEAVE";
StackMenu[StackMenu["TOKENS_BACKUP"] = 20] = "TOKENS_BACKUP";
StackMenu[StackMenu["TOKENS_RESTORE"] = 21] = "TOKENS_RESTORE";
StackMenu[StackMenu["TOKENS_RESET"] = 22] = "TOKENS_RESET";
StackMenu[StackMenu["TOKENS_WRITE_EUI64"] = 23] = "TOKENS_WRITE_EUI64";
StackMenu[StackMenu["SECURITY_INFO"] = 30] = "SECURITY_INFO";
StackMenu[StackMenu["ZIGBEE2MQTT_ONBOARD"] = 40] = "ZIGBEE2MQTT_ONBOARD";
StackMenu[StackMenu["REPAIRS"] = 99] = "REPAIRS";
})(StackMenu || (StackMenu = {}));
const BULLET_FULL = "\u2022";
const BULLET_EMPTY = "\u2219";
export default class Stack extends Command {
static args = {};
static description = "Interact with the EmberZNet stack in the adapter.";
static examples = ["<%= config.bin %> <%= command.id %>"];
static flags = {};
async run() {
// const {flags} = await this.parse(Stack)
const portConf = await getPortConf();
logger.debug(`Using port conf: ${JSON.stringify(portConf)}`);
let ezsp = await emberStart(portConf);
let exit = false;
while (!exit) {
exit = await this.navigateMenu(ezsp);
if (exit) {
const restart = await confirm({
default: true,
message: "Restart? (If no, exit)",
});
if (restart) {
await emberStop(ezsp);
ezsp = await emberStart(portConf);
exit = false;
}
}
}
await emberStop(ezsp);
return this.exit(0);
}
async menuNetworkBackup(ezsp) {
const saveFile = await browseToFile("Network backup save file", DEFAULT_NETWORK_BACKUP_PATH, true);
const initStatus = await emberNetworkInit(ezsp);
if (initStatus === SLStatus.NOT_JOINED) {
logger.error("No network present.");
return true;
}
if (initStatus !== SLStatus.OK) {
logger.error(`Failed network init request with status=${SLStatus[initStatus]}.`);
return true;
}
await waitForStackStatus(ezsp, SLStatus.NETWORK_UP);
const [netStatus, , netParams] = await ezsp.ezspGetNetworkParameters();
if (netStatus !== SLStatus.OK) {
logger.error(`Failed to get network parameters with status=${SLStatus[netStatus]}.`);
return true;
}
const eui64 = await ezsp.ezspGetEui64();
const [netKeyStatus, netKeyInfo] = await ezsp.ezspGetNetworkKeyInfo();
if (netKeyStatus !== SLStatus.OK) {
logger.error(`Failed to get network keys info with status=${SLStatus[netKeyStatus]}.`);
return true;
}
if (!netKeyInfo.networkKeySet) {
logger.error("No network key set.");
return true;
}
const [confStatus, keyTableSize] = await ezsp.ezspGetConfigurationValue(EzspConfigId.KEY_TABLE_SIZE);
if (confStatus !== SLStatus.OK) {
logger.error(`Failed to retrieve key table size from NCP with status=${SLStatus[confStatus]}.`);
return true;
}
const keyList = [];
for (let i = 0; i < keyTableSize; i++) {
const [status, context, plaintextKey, apsKeyMeta] = await ezsp.ezspExportLinkKeyByIndex(i);
logger.debug(`Export link key at index ${i}, status=${SLStatus[status]}.`);
// only include key if we could retrieve one at index and hash it properly
if (status === SLStatus.OK) {
// Rather than give the real link key, the backup contains a hashed version of the key.
// This is done to prevent a compromise of the backup data from compromising the current link keys.
// This is per the Smart Energy spec.
const [hashStatus, returnContext] = await ezsp.ezspAesMmoHash({ result: Buffer.alloc(EMBER_AES_HASH_BLOCK_SIZE), length: 0x00000000 }, true, plaintextKey.contents);
if (hashStatus === SLStatus.OK) {
keyList.push({
deviceEui64: context.eui64,
key: { contents: returnContext.result },
outgoingFrameCounter: apsKeyMeta.outgoingFrameCounter,
incomingFrameCounter: apsKeyMeta.incomingFrameCounter,
});
}
else {
// this should never happen?
logger.error(`Failed to hash link key at index ${i} with status=${SLStatus[hashStatus]}. Omitting from backup.`);
}
}
}
logger.info(`Retrieved ${keyList.length} link keys.`);
let context = initSecurityManagerContext();
context.coreKeyType = SecManKeyType.TC_LINK;
const [tclkStatus, tcLinkKey] = await ezsp.ezspExportKey(context);
if (tclkStatus !== SLStatus.OK) {
logger.error(`Failed to export TC Link Key with status=${SLStatus[tclkStatus]}.`);
return true;
}
context = initSecurityManagerContext(); // make sure it's back to zeroes
context.coreKeyType = SecManKeyType.NETWORK;
context.keyIndex = 0;
const [nkStatus, networkKey] = await ezsp.ezspExportKey(context);
if (nkStatus !== SLStatus.OK) {
logger.error(`Failed to export Network Key with status=${SLStatus[nkStatus]}.`);
return true;
}
const backup = {
coordinatorIeeeAddress: Buffer.from(eui64.slice(2) /* take out 0x */, "hex").reverse(),
devices: keyList.map((key) => ({
networkAddress: ZSpec.NULL_NODE_ID, // not used for restore, no reason to make NCP calls for nothing
ieeeAddress: Buffer.from(key.deviceEui64.slice(2) /* take out 0x */, "hex").reverse(),
isDirectChild: false, // not used
linkKey: {
key: key.key.contents,
rxCounter: key.incomingFrameCounter,
txCounter: key.outgoingFrameCounter,
},
})),
ezsp: {
hashed_tclk: tcLinkKey.contents,
version: emberFullVersion.ezsp,
// altNetworkKey: altNetworkKey.contents,
},
logicalChannel: netParams.radioChannel,
networkKeyInfo: {
frameCounter: netKeyInfo.networkKeyFrameCounter,
sequenceNumber: netKeyInfo.networkKeySequenceNumber,
},
networkOptions: {
channelList: ZSpec.Utils.uint32MaskToChannels(netParams.channels),
extendedPanId: Buffer.from(netParams.extendedPanId),
networkKey: networkKey.contents,
networkKeyDistribute: false,
panId: netParams.panId, // uint16_t
},
networkUpdateId: netParams.nwkUpdateId,
securityLevel: 5, // Z3.0
};
const unifiedBackup = await toUnifiedBackup(backup);
writeFileSync(saveFile, JSON.stringify(unifiedBackup, null, 2), "utf8");
logger.info(`Network backup written to '${saveFile}'.`);
return true;
}
async menuNetworkInfo(ezsp) {
const initStatus = await emberNetworkInit(ezsp);
if (initStatus === SLStatus.NOT_JOINED) {
logger.error("No network present.");
return true;
}
if (initStatus !== SLStatus.OK) {
logger.error(`Failed network init request with status=${SLStatus[initStatus]}.`);
return true;
}
await waitForStackStatus(ezsp, SLStatus.NETWORK_UP);
const [npStatus, nodeType, netParams] = await ezsp.ezspGetNetworkParameters();
if (npStatus !== SLStatus.OK) {
logger.error(`Failed to get network parameters with status=${SLStatus[npStatus]}.`);
return true;
}
const eui64 = await ezsp.ezspGetEui64();
const [netKeyStatus, netKeyInfo] = await ezsp.ezspGetNetworkKeyInfo();
if (netKeyStatus !== SLStatus.OK) {
throw new Error(`[BACKUP] Failed to get network keys info with status=${SLStatus[netKeyStatus]}.`);
}
const context = initSecurityManagerContext();
context.coreKeyType = SecManKeyType.TC_LINK;
const [tcKeyStatus, tcKeyInfo] = await ezsp.ezspGetApsKeyInfo(context);
if (tcKeyStatus !== SLStatus.OK) {
throw new Error(`[BACKUP] Failed to get TC APS key info with status=${SLStatus[tcKeyStatus]}.`);
}
logger.info(`Node EUI64=${eui64} type=${EmberNodeType[nodeType]}.`);
logger.info("Network parameters:");
logger.info(` - PAN ID: ${netParams.panId} (${toHex(netParams.panId)})`);
logger.info(` - Extended PAN ID: ${netParams.extendedPanId}`);
logger.info(` - Radio Channel: ${netParams.radioChannel}`);
logger.info(` - Radio Power: ${netParams.radioTxPower} dBm`);
logger.info(` - Preferred Channels: ${ZSpec.Utils.uint32MaskToChannels(netParams.channels).join(",")}`);
logger.info("Network key info:");
logger.info(` - Set? ${netKeyInfo.networkKeySet ? "yes" : "no"}`);
logger.info(` - Sequence Number: ${netKeyInfo.networkKeySequenceNumber}`);
logger.info(` - Frame Counter: ${netKeyInfo.networkKeyFrameCounter}`);
logger.info(` - Alt Set? ${netKeyInfo.alternateNetworkKeySet ? "yes" : "no"}`);
logger.info(` - Alt Sequence Number: ${netKeyInfo.altNetworkKeySequenceNumber}`);
logger.info("Trust Center link key info:");
logger.info(` - Properties: ${getKeyStructBitmask(tcKeyInfo.bitmask)}`);
logger.info(` - Incoming Frame Counter: ${tcKeyInfo.incomingFrameCounter}`);
logger.info(` - Outgoing Frame Counter: ${tcKeyInfo.outgoingFrameCounter}`);
return true;
}
async menuNetworkLeave(ezsp) {
const confirmed = await confirm({
default: false,
message: "Confirm leave network? (Cannot be undone without a backup.)",
});
if (!confirmed) {
logger.info("Network leave cancelled.");
return false;
}
const initStatus = await emberNetworkInit(ezsp);
if (initStatus === SLStatus.NOT_JOINED) {
logger.info("No network present.");
return true;
}
if (initStatus !== SLStatus.OK) {
logger.error(`Failed network init request with status=${SLStatus[initStatus]}.`);
return true;
}
await waitForStackStatus(ezsp, SLStatus.NETWORK_UP);
const leaveStatus = await ezsp.ezspLeaveNetwork();
if (leaveStatus !== SLStatus.OK) {
logger.error(`Failed to leave network with status=${SLStatus[leaveStatus]}.`);
return true;
}
await waitForStackStatus(ezsp, SLStatus.NETWORK_DOWN);
logger.info("Left network.");
return true;
}
async menuNetworkRestore(ezsp) {
const backupFile = await browseToFile("Network backup file location", DEFAULT_NETWORK_BACKUP_PATH);
const backup = getBackupFromFile(backupFile);
if (backup === undefined) {
// error logged in getBackupFromFile
return false;
}
if (!backup.ezsp) {
const confirmed = await confirm({ message: "Backup file is not for EmberZNet stack. Restore anyway?", default: false });
if (!confirmed) {
logger.info("Restore cancelled.");
return false;
}
}
if (!backup.ezsp?.hashed_tclk) {
logger.debug("Backup file does not contain the Trust Center Link Key. Generating random one.");
// don't care about version here, so just overwrite the whole `ezsp` object
backup.ezsp = { hashed_tclk: randomBytes(EMBER_ENCRYPTION_KEY_SIZE) };
}
const radioTxPower = Number.parseInt(await input({
default: "5",
message: "Radio transmit power [-128-127]",
validate(value) {
if (/\./.test(value)) {
return false;
}
const v = Number.parseInt(value, 10);
return v >= -128 && v <= 127;
},
}), 10);
let status = await emberNetworkInit(ezsp);
const noNetwork = status === SLStatus.NOT_JOINED;
if (!noNetwork && status !== SLStatus.OK) {
logger.error(`Failed network init request with status=${SLStatus[status]}.`);
return true;
}
if (!noNetwork) {
const overwrite = await confirm({
default: false,
message: "A network is present in the adapter. Leave and continue restoring?",
});
if (!overwrite) {
logger.info("Restore cancelled.");
return true;
}
status = await ezsp.ezspLeaveNetwork();
if (status !== SLStatus.OK) {
logger.error(`Failed to leave network with status=${SLStatus[status]}.`);
return true;
}
await waitForStackStatus(ezsp, SLStatus.NETWORK_DOWN);
}
// before forming
const keyList = backup.devices.map((device) => {
const octets = [...device.ieeeAddress.reverse()];
return {
deviceEui64: `0x${octets.map((octet) => octet.toString(16).padStart(2, "0")).join("")}`,
// won't export if linkKey not present, so should always be valid here
key: { contents: device.linkKey.key },
outgoingFrameCounter: device.linkKey.txCounter,
incomingFrameCounter: device.linkKey.rxCounter,
};
});
if (keyList.length > 0) {
const [confStatus, keyTableSize] = await ezsp.ezspGetConfigurationValue(EzspConfigId.KEY_TABLE_SIZE);
if (confStatus !== SLStatus.OK) {
logger.error(`Failed to retrieve key table size from NCP with status=${SLStatus[confStatus]}.`);
return true;
}
if (keyList.length > keyTableSize) {
logger.error(`Current key table of ${keyTableSize} is too small to import backup of ${keyList.length}!`);
return true;
}
let status;
for (let i = 0; i < keyTableSize; i++) {
// erase any key index not present in backup but available on the NCP
status =
i >= keyList.length
? await ezsp.ezspEraseKeyTableEntry(i)
: await ezsp.ezspImportLinkKey(i, keyList[i].deviceEui64, keyList[i].key);
if (status !== SLStatus.OK) {
logger.error(`Failed to ${i >= keyList.length ? "erase" : "set"} key table entry at index ${i} with status=${SLStatus[status]}`);
}
}
logger.info(`Imported ${keyList.length} keys.`);
}
// status = await ezsp.ezspSetNWKFrameCounter(backup.networkKeyInfo.frameCounter)
// if (status !== SLStatus.OK) {
// logger.error(`Failed to set NWK frame counter to ${backup.networkKeyInfo.frameCounter} with status=${SLStatus[status]}.`)
// return true
// }
// status = await ezsp.ezspSetAPSFrameCounter(backup.tcLinkKeyInfo.outgoingFrameCounter)
// if (status !== SLStatus.OK) {
// logger.error(`Failed to set TC APS frame counter to ${backup.tcLinkKeyInfo.outgoingFrameCounter} with status=${SLStatus[status]}.`)
// return true
// }
const state = {
bitmask: EmberInitialSecurityBitmask.TRUST_CENTER_GLOBAL_LINK_KEY |
EmberInitialSecurityBitmask.HAVE_PRECONFIGURED_KEY |
EmberInitialSecurityBitmask.HAVE_NETWORK_KEY |
EmberInitialSecurityBitmask.TRUST_CENTER_USES_HASHED_LINK_KEY |
EmberInitialSecurityBitmask.REQUIRE_ENCRYPTED_KEY |
EmberInitialSecurityBitmask.NO_FRAME_COUNTER_RESET,
networkKey: { contents: backup.networkOptions.networkKey },
networkKeySequenceNumber: backup.networkKeyInfo.sequenceNumber,
preconfiguredKey: { contents: backup.ezsp.hashed_tclk }, // presence validated above
preconfiguredTrustCenterEui64: ZSpec.BLANK_EUI64,
};
status = await ezsp.ezspSetInitialSecurityState(state);
if (status !== SLStatus.OK) {
logger.error(`Failed to set initial security state with status=${SLStatus[status]}.`);
return true;
}
const extended = EmberExtendedSecurityBitmask.JOINER_GLOBAL_LINK_KEY | EmberExtendedSecurityBitmask.NWK_LEAVE_REQUEST_NOT_ALLOWED;
status = await ezsp.ezspSetExtendedSecurityBitmask(extended);
if (status !== SLStatus.OK) {
logger.error(`Failed to set extended security bitmask to ${extended} with status=${SLStatus[status]}.`);
return true;
}
const netParams = {
channels: ZSpec.ALL_802_15_4_CHANNELS_MASK,
extendedPanId: [...backup.networkOptions.extendedPanId],
joinMethod: EmberJoinMethod.MAC_ASSOCIATION,
nwkManagerId: ZSpec.COORDINATOR_ADDRESS,
nwkUpdateId: 0,
panId: backup.networkOptions.panId,
radioChannel: backup.logicalChannel,
radioTxPower,
};
logger.info(`Forming new network with: ${JSON.stringify(netParams)}`);
status = await ezsp.ezspFormNetwork(netParams);
if (status !== SLStatus.OK) {
logger.error(`Failed form network request with status=${SLStatus[status]}.`);
return true;
}
await waitForStackStatus(ezsp, SLStatus.NETWORK_UP);
const stStatus = await ezsp.ezspStartWritingStackTokens();
logger.debug(`Start writing stack tokens status=${SLStatus[stStatus]}.`);
logger.info("New network formed!");
const [netStatus, , parameters] = await ezsp.ezspGetNetworkParameters();
if (netStatus !== SLStatus.OK) {
logger.error(`Failed to get network parameters with status=${SLStatus[netStatus]}.`);
return true;
}
if (parameters.panId === backup.networkOptions.panId &&
Buffer.from(parameters.extendedPanId).equals(backup.networkOptions.extendedPanId) &&
parameters.radioChannel === backup.logicalChannel) {
logger.info("Restored network backup.");
}
else {
logger.error("Failed to restore network backup.");
}
return true; // cleaner to exit after this
}
async menuNetworkScan(ezsp) {
const radioTxPower = Number.parseInt(await input({
default: "5",
message: "Radio transmit power [-128-127]",
validate(value) {
if (/\./.test(value)) {
return false;
}
const v = Number.parseInt(value, 10);
return v >= -128 && v <= 127;
},
}), 10);
const status = await ezsp.ezspSetRadioPower(radioTxPower);
if (status !== SLStatus.OK) {
logger.error(`Failed to set transmit power to ${radioTxPower} status=${SLStatus[status]}.`);
return true;
}
const scanType = await select({
choices: [
{ name: "Scan each channel for its RSSI value", value: EzspNetworkScanType.ENERGY_SCAN },
{ name: "Scan each channel for existing networks", value: EzspNetworkScanType.ACTIVE_SCAN },
],
message: "Type of scan",
});
// WiFi beacon frames at standard interval: 102.4msec
const duration = await select({
choices: [
{ name: "3948 msec", value: 8 },
{ name: "1981 msec", value: 7 },
{ name: "998 msec", value: 6 },
{ name: "507 msec", value: 5 },
{ name: "261 msec", value: 4 },
{ name: "138 msec", value: 3 },
{ name: "77 msec", value: 2 },
{ name: "46 msec", value: 1 },
{ name: "31 msec", value: 0 },
],
default: 6,
message: "Duration of scan per channel",
});
const progressBar = new SingleBar({ clearOnComplete: true, format: "{bar} {percentage}% | ETA: {eta}s" }, Presets.shades_classic);
// a symbol is 16 microseconds, a scan period is 960 symbols
const totalTime = (((2 ** duration + 1) * (16 * 960)) / 1000) * ZSpec.ALL_802_15_4_CHANNELS.length;
let scanCompleted;
const reportedValues = [];
// NOTE: expanding zigbee-herdsman
const ezspEnergyScanResultHandlerOriginal = ezsp.ezspEnergyScanResultHandler;
const ezspNetworkFoundHandlerOriginal = ezsp.ezspNetworkFoundHandler;
const ezspScanCompleteHandlerOriginal = ezsp.ezspScanCompleteHandler;
ezsp.ezspEnergyScanResultHandler = (channel, maxRssiValue) => {
logger.debug(`ezspEnergyScanResultHandler: ${JSON.stringify({ channel, maxRssiValue })}`);
const full = 90 + maxRssiValue;
const empty = 90 - full;
if (full < 1 || empty < 1) {
reportedValues.push(`Channel ${channel}: ERROR`);
}
else {
reportedValues.push(`Channel ${channel}: ${BULLET_FULL.repeat(full)}${BULLET_EMPTY.repeat(empty)} [${maxRssiValue} dBm]`);
}
};
ezsp.ezspNetworkFoundHandler = (networkFound, lastHopLqi, lastHopRssi) => {
logger.debug(`ezspNetworkFoundHandler: ${JSON.stringify({ networkFound, lastHopLqi, lastHopRssi })}`);
reportedValues.push("Found network:", ` - PAN ID: ${networkFound.panId}`, ` - Ext PAN ID: ${networkFound.extendedPanId}`, ` - Channel: ${networkFound.channel}`, ` - Allowing join: ${networkFound.allowingJoin ? "yes" : "no"}`, ` - Node RSSI: ${lastHopRssi} dBm | LQI: ${lastHopLqi}`);
};
ezsp.ezspScanCompleteHandler = (channel, status) => {
logger.debug(`ezspScanCompleteHandler: ${JSON.stringify({ channel, status })}`);
progressBar.stop();
clearInterval(progressInterval);
if (status === SLStatus.OK) {
if (scanCompleted) {
scanCompleted();
}
}
else {
logger.error(`Failed to scan ${channel} with status=${SLStatus[status]}.`);
}
};
const startScanStatus = await ezsp.ezspStartScan(scanType, ZSpec.ALL_802_15_4_CHANNELS_MASK, duration);
if (startScanStatus !== SLStatus.OK) {
logger.error(`Failed start scan request with status=${SLStatus[startScanStatus]}.`);
// restore zigbee-herdsman default
ezsp.ezspEnergyScanResultHandler = ezspEnergyScanResultHandlerOriginal;
ezsp.ezspNetworkFoundHandler = ezspNetworkFoundHandlerOriginal;
ezsp.ezspScanCompleteHandler = ezspScanCompleteHandlerOriginal;
return true;
}
progressBar.start(totalTime, 0);
const progressInterval = setInterval(() => {
progressBar.increment(500);
}, 500);
await new Promise((resolve) => {
scanCompleted = resolve;
});
for (const line of reportedValues) {
logger.info(line);
}
// restore zigbee-herdsman default
ezsp.ezspEnergyScanResultHandler = ezspEnergyScanResultHandlerOriginal;
ezsp.ezspNetworkFoundHandler = ezspNetworkFoundHandlerOriginal;
ezsp.ezspScanCompleteHandler = ezspScanCompleteHandlerOriginal;
return false;
}
async menuRepairs(ezsp) {
let RepairId;
(function (RepairId) {
RepairId[RepairId["EUI64_MISMATCH"] = 0] = "EUI64_MISMATCH";
})(RepairId || (RepairId = {}));
const repairId = await select({
choices: [
{ name: "Check for EUI64 mismatch", value: RepairId.EUI64_MISMATCH },
{ name: "Go Back", value: -1 },
],
message: "Repair",
});
switch (repairId) {
case RepairId.EUI64_MISMATCH: {
const initStatus = await emberNetworkInit(ezsp);
if (initStatus === SLStatus.NOT_JOINED) {
logger.info("No network present.");
return true;
}
if (initStatus !== SLStatus.OK) {
logger.error(`Failed network init request with status=${SLStatus[initStatus]}.`);
return true;
}
const [status, securityState] = await ezsp.ezspGetCurrentSecurityState();
if (status !== SLStatus.OK) {
logger.error(`Failed get current security state request with status=${SLStatus[status]}.`);
return true;
}
const eui64 = await ezsp.ezspGetEui64();
logger.info(`Node EUI64 ${eui64} / Trust Center EUI64 ${securityState.trustCenterLongAddress}.`);
if (securityState.trustCenterLongAddress === eui64) {
logger.info("EUI64 match. No fix required.");
return true;
}
logger.warning("Fixing EUI64 mismatch...");
const [gtkStatus, tokenData] = await ezsp.ezspGetTokenData(NVM3ObjectKey.STACK_TRUST_CENTER, 0);
if (gtkStatus !== SLStatus.OK) {
logger.error(`Failed get token data request with status=${SLStatus[gtkStatus]}.`);
return true;
}
const tokenEUI64 = tokenData.data.subarray(2, 10);
const tcEUI64 = Buffer.from(securityState.trustCenterLongAddress.slice(2 /* 0x */), "hex").reverse();
if (tokenEUI64.equals(tcEUI64)) {
tokenData.data.set(Buffer.from(eui64.slice(2 /* 0x */), "hex").reverse(), 2 /* skip uint16_t at start */);
const stkStatus = await ezsp.ezspSetTokenData(NVM3ObjectKey.STACK_TRUST_CENTER, 0, tokenData);
if (stkStatus !== SLStatus.OK) {
logger.error(`Failed set token data request with status=${SLStatus[stkStatus]}.`);
return true;
}
}
else {
logger.error(`Failed to fix EUI64 mismatch. NVM3 Trust Center token doesn't match current security state.`);
return true;
}
break;
}
case -1: {
return false;
}
}
return true;
}
async menuSecurityInfo(ezsp) {
{
const context = initSecurityManagerContext();
context.coreKeyType = SecManKeyType.TC_LINK;
const [status, key] = await ezsp.ezspExportKey(context);
if (status === SLStatus.OK) {
logger.info(`Trust Center Link Key: ${key.contents.toString("hex")}`);
}
else if (status !== SLStatus.NOT_FOUND) {
logger.error(`Failed to export Trust Center Link Key with status=${SLStatus[status]}.`);
}
}
{
const context = initSecurityManagerContext();
context.coreKeyType = SecManKeyType.APP_LINK;
const [status, key] = await ezsp.ezspExportKey(context);
if (status === SLStatus.OK) {
logger.info(`App Link Key: ${key.contents.toString("hex")}`);
}
else if (status !== SLStatus.NOT_FOUND) {
logger.error(`Failed to export App Link Key with status=${SLStatus[status]}.`);
}
}
{
const context = initSecurityManagerContext();
context.coreKeyType = SecManKeyType.NETWORK;
context.keyIndex = 0;
const [status, key] = await ezsp.ezspExportKey(context);
if (status === SLStatus.OK) {
logger.info(`Network Key: ${key.contents.toString("hex")}`);
}
else if (status !== SLStatus.NOT_FOUND) {
logger.error(`Failed to export Network Key with status=${SLStatus[status]}.`);
}
}
{
const context = initSecurityManagerContext();
context.coreKeyType = SecManKeyType.ZLL_ENCRYPTION_KEY;
const [status, key] = await ezsp.ezspExportKey(context);
if (status === SLStatus.OK) {
logger.info(`ZLL Encryption Key: ${key.contents.toString("hex")}`);
}
else if (status !== SLStatus.NOT_FOUND) {
logger.error(`Failed to export ZLL Encryption Key with status=${SLStatus[status]}.`);
}
}
{
const context = initSecurityManagerContext();
context.coreKeyType = SecManKeyType.ZLL_PRECONFIGURED_KEY;
const [status, key] = await ezsp.ezspExportKey(context);
if (status === SLStatus.OK) {
logger.info(`ZLL Preconfigured Key: ${key.contents.toString("hex")}`);
}
else if (status !== SLStatus.NOT_FOUND) {
logger.error(`Failed to export ZLL Preconfigured Key with status=${SLStatus[status]}.`);
}
}
{
const context = initSecurityManagerContext();
context.coreKeyType = SecManKeyType.GREEN_POWER_PROXY_TABLE_KEY;
const [status, key] = await ezsp.ezspExportKey(context);
if (status === SLStatus.OK) {
logger.info(`Green Power Proxy Table Key: ${key.contents.toString("hex")}`);
}
else if (status !== SLStatus.NOT_FOUND) {
logger.error(`Failed to export Green Power Proxy Table Key with status=${SLStatus[status]}.`);
}
}
{
const context = initSecurityManagerContext();
context.coreKeyType = SecManKeyType.GREEN_POWER_SINK_TABLE_KEY;
const [status, key] = await ezsp.ezspExportKey(context);
if (status === SLStatus.OK) {
logger.info(`Green Power Sink Table Key: ${key.contents.toString("hex")}`);
}
else if (status !== SLStatus.NOT_FOUND) {
logger.error(`Failed to export Green Power Sink Table Key with status=${SLStatus[status]}.`);
}
}
return false;
}
async menuStackConfig(ezsp) {
let saveFile;
if (await confirm({ default: false, message: "Save to file? (Only print if not)" })) {
saveFile = await browseToFile("Config save location (JSON)", DEFAULT_STACK_CONFIG_PATH, true);
}
const stackConfig = {};
for (const key of Object.keys(EzspConfigId)) {
const configId = EzspConfigId[key];
if (typeof configId !== "number") {
continue;
}
const [status, value] = await ezsp.ezspGetConfigurationValue(configId);
stackConfig[`CONFIG.${key}`] = status === SLStatus.OK ? `${value}` : SLStatus[status];
}
{
// needs special handling due to bitmask, excluded from below for-loop
const [status, value] = await ezsp.ezspGetPolicy(EzspPolicyId.TRUST_CENTER_POLICY);
const tcDecisions = [];
for (const key of Object.keys(EzspDecisionBitmask)) {
const bitmask = EzspDecisionBitmask[key];
if (typeof bitmask !== "number") {
continue;
}
if ((value & bitmask) !== 0) {
tcDecisions.push(key);
}
}
stackConfig["POLICY.TRUST_CENTER_POLICY"] = status === SLStatus.OK ? tcDecisions.join(",") : SLStatus[status];
}
for (const key of Object.keys(EzspPolicyId)) {
const policyId = EzspPolicyId[key];
if (typeof policyId !== "number" || policyId === EzspPolicyId.TRUST_CENTER_POLICY) {
continue;
}
const [status, value] = await ezsp.ezspGetPolicy(policyId);
stackConfig[`POLICY.${key}`] = status === SLStatus.OK ? EzspDecisionId[value] : SLStatus[status];
}
{
// needs special handling due to zero-conflict with `FIRST`, excluded from below for-loop
const status = await ezsp.ezspGetLibraryStatus(EmberLibraryId.ZIGBEE_PRO);
stackConfig["LIBRARY.ZIGBEE_PRO"] = getLibraryStatus(EmberLibraryId.ZIGBEE_PRO, status);
}
for (let i = EmberLibraryId.FIRST + 1; i < EmberLibraryId.NUMBER_OF_LIBRARIES; i++) {
const status = await ezsp.ezspGetLibraryStatus(i);
stackConfig[`LIBRARY.${EmberLibraryId[i]}`] = getLibraryStatus(i, status);
}
// Note: others are either removed (legacy) or not useful
for (const tokenId of [
EzspMfgTokenId.CUSTOM_VERSION,
EzspMfgTokenId.BOARD_NAME,
EzspMfgTokenId.MANUF_ID,
EzspMfgTokenId.PHY_CONFIG,
EzspMfgTokenId.ASH_CONFIG,
EzspMfgTokenId.CBKE_DATA,
EzspMfgTokenId.INSTALLATION_CODE,
EzspMfgTokenId.CUSTOM_EUI_64,
EzspMfgTokenId.CTUNE,
]) {
try {
const [, tokenData] = await ezsp.ezspGetMfgToken(tokenId);
stackConfig[`MFG_TOKEN.${EzspMfgTokenId[tokenId]}`] = `${tokenData.join(",")}`;
}
catch { }
}
for (const key of Object.keys(stackConfig)) {
logger.info(`${key} = ${stackConfig[key]}.`);
}
if (saveFile !== undefined) {
writeFileSync(saveFile, JSON.stringify(stackConfig, null, 2), "utf8");
logger.info(`Stack config written to '${saveFile}'.`);
}
return false;
}
menuStackInfo() {
logger.info(`EmberZNet: ${emberFullVersion.revision}. EZSP: ${emberFullVersion.ezsp}`);
return false;
}
async menuTokensBackup(ezsp) {
const saveFile = await browseToFile("Tokens backup save file", DEFAULT_TOKENS_BACKUP_PATH, true);
const eui64 = await ezsp.ezspGetEui64();
const tokensBuf = await EmberTokensManager.saveTokens(ezsp, Buffer.from(eui64.slice(2 /* 0x */), "hex").reverse());
if (tokensBuf) {
writeFileSync(saveFile, tokensBuf.toString("hex"), "utf8");
logger.info(`Tokens backup written to '${saveFile}'.`);
}
else {
logger.error("Failed to backup tokens.");
}
return false;
}
async menuTokensReset(ezsp) {
const confirmed = await confirm({
default: false,
message: "Confirm tokens reset? (Cannot be undone without a backup.)",
});
if (!confirmed) {
logger.info("Tokens reset cancelled.");
return false;
}
const options = await checkbox({
choices: [
{ checked: false, name: "Exclude network and APS outgoing frame counter tokens?", value: "excludeOutgoingFC" },
{ checked: false, name: "Exclude stack boot counter token?", value: "excludeBootCounter" },
],
message: "Reset options",
});
await ezsp.ezspTokenFactoryReset(options.includes("excludeOutgoingFC"), options.includes("excludeBootCounter"));
return true;
}
async menuTokensRestore(ezsp) {
const backupFile = await browseToFile("Tokens backup file location", DEFAULT_TOKENS_BACKUP_PATH);
let tokensBuf = Buffer.from(readFileSync(backupFile, "utf8"), "hex");
{
// check for binding table corrupting NVM3 if size is too large (32 tested as "safe")
let bindingTableMod;
let readOffset = 0;
const inTokenCount = tokensBuf.readUInt8(readOffset++);
for (let i = 0; i < inTokenCount; i++) {
const nvm3Key = tokensBuf.readUInt32LE(readOffset); // 4 bytes Token Key/Creator
readOffset += 4;
const size = tokensBuf.readUInt8(readOffset++); // 1 byte token size
const arraySize = tokensBuf.readUInt8(readOffset++); // 1 byte array size.
if (nvm3Key === NVM3ObjectKey.STACK_BINDING_TABLE && arraySize > 32) {
logger.warning("Binding table is too large, which is known to corrupt NVM3, keeping only first 32 entries.");
bindingTableMod = {
arraySizeOffset: readOffset - 1,
clipStartOffset: readOffset + 32 * size,
clipEndOffset: readOffset + arraySize * size,
};
}
readOffset += arraySize * size;
}
if (bindingTableMod) {
tokensBuf[bindingTableMod.arraySizeOffset] = 32;
const tokensBufStart = tokensBuf.subarray(0, bindingTableMod.clipStartOffset);
const tokensBufEnd = tokensBuf.subarray(bindingTableMod.clipEndOffset);
tokensBuf = Buffer.concat([tokensBufStart, tokensBufEnd]);
const saveFile = `${backupFile}-fixed.nvm3`;
writeFileSync(saveFile, tokensBuf.toString("hex"), "utf8");
logger.info(`Fixed tokens backup written to '${saveFile}'.`);
}
}
const status = await EmberTokensManager.restoreTokens(ezsp, tokensBuf);
if (status === SLStatus.OK) {
logger.info("Restored tokens.");
}
else {
logger.error("Failed to restore tokens.");
}
return true;
}
async menuTokensWriteEUI64(ezsp) {
let tokenKey;
const currentEUI64 = await ezsp.ezspGetEui64();
logger.info(`Current EUI64: ${currentEUI64} (${Buffer.from(currentEUI64.slice(2), "hex").reverse().toString("hex")}).`);
for (const key of [NVM3ObjectKey.STACK_RESTORED_EUI64, CREATOR_STACK_RESTORED_EUI64]) {
const [status, tokenData] = await ezsp.ezspGetTokenData(key, 0);
if (status === SLStatus.OK) {
logger.info(`Current restored EUI64 token (${key}): ${eui64LEBufferToHex(tokenData.data)} (${tokenData.data.toString("hex")}).`);
tokenKey = key;
break;
}
}
if (tokenKey === undefined) {
logger.error("Unable to write EUI64, operation not supported by firmware.");
return false;
}
let Source;
(function (Source) {
Source[Source["FILE"] = 0] = "FILE";
Source[Source["INPUT"] = 1] = "INPUT";
})(Source || (Source = {}));
const source = await select({
choices: [
{ name: "From coordinator backup file", value: Source.FILE },
{ name: `From manual input (format: ${ZSpec.BLANK_EUI64})`, value: Source.INPUT },
{ name: "Go Back", value: -1 },
],
message: "Source for the EUI64",
});
let eui64Hex;
let eui64;
switch (source) {
case Source.FILE: {
const backupFile = await browseToFile("File location", DEFAULT_NETWORK_BACKUP_PATH);
const backup = getBackupFromFile(backupFile);
if (backup === undefined) {
// error logged in getBackupFromFile
return false;
}
eui64 = backup.coordinatorIeeeAddress;
eui64Hex = `0x${Buffer.from(eui64).reverse().toString("hex")}`;
break;
}
case Source.INPUT: {
eui64Hex = await input({
message: "EUI64",
default: ZSpec.BLANK_EUI64,
validate(value) {
return /^0x[0-9a-f]{16}$/i.test(value);
},
});
eui64 = Buffer.from(eui64Hex.slice(2 /* 0x */), "hex").reverse();
break;
}
case -1: {
return false;
}
}
if (!eui64) {
logger.error("Invalid EUI64, cannot procede.");
return false;
}
logger.info(`Writing EUI64 ${eui64Hex} (${eui64.toString("hex")}).`);
const status = await ezsp.ezspSetTokenData(tokenKey, 0, { data: eui64, size: eui64.length });
if (status !== SLStatus.OK) {
logger.error(`Failed to write EUI64 with status=${SLStatus[status]}.`);
}
return true;
}
async menuZigbee2MQTTOnboard(ezsp) {
let status = await emberNetworkInit(ezsp);
const notJoined = status === SLStatus.NOT_JOINED;
if (!notJoined && status !== SLStatus.OK) {
logger.error(`Failed network init request with status=${SLStatus[status]}.`);
return true;
}
if (!notJoined) {
const overwrite = await confirm({ default: false, message: "A network is present in the adapter. Leave and continue onboard?" });
if (!overwrite) {
logger.info("Onboard cancelled.");
return false;
}
const status = await ezsp.ezspLeaveNetwork();
if (status !== SLStatus.OK) {
logger.error(`Failed to leave network with status=${SLStatus[status]}.`);
return true;
}
await waitForStackStatus(ezsp, SLStatus.NETWORK_DOWN);
}
// set desired tx power before scan
const radioTxPower = Number.parseInt(await input({
default: "5",
message: "Radio transmit power [-128-127]",
validate(value) {
if (/\./.test(value)) {
return false;
}
const v = Number.parseInt(value, 10);
return v >= -128 && v <= 127;
},
}), 10);
status = await ezsp.ezspSetRadioPower(radioTxPower);
if (status !== SLStatus.OK) {
logger.error(`Failed to set transmit power to ${radioTxPower} status=${SLStatus[status]}.`);
return true;
}
// WiFi beacon frames at standard interval: 102.4msec
const duration = await select({
choices: [
{ name: "3948 msec", value: 8 },
{ name: "1981 msec", value: 7 },
{ name: "998 msec", value: 6 },
{ name: "507 msec", value: 5 },
{ name: "261 msec", value: 4 },
{ name: "138 msec", value: 3 },
// { name: '77 msec', value: 2 },
// { name: '46 msec', value: 1 },
// { name: '31 msec', value: 0 },
],
default: 6,
message: "Duration of scan per channel",
});
const channels = await checkbox({
choices: ZSpec.ALL_802_15_4_CHANNELS.map((c) => ({ name: c.toString(), value: c, checked: TOUCHLINK_CHANNELS.includes(c) })),
message: "Channels to consider",
required: true,
});
const progressBar = new SingleBar({ clearOnComplete: true, format: "{bar} {percentage}% | ETA: {eta}s" }, Presets.shades_classic);
// a symbol is 16 microseconds, a scan period is 960 symbols
const totalTime = (((2 ** duration + 1) * (16 * 960)) / 1000) * channels.length;
let scanCompleted;
// NOTE: expanding zigbee-herdsman
const ezspUnusedPanIdFoundHandlerOriginal = ezsp.ezspUnusedPanIdFoundHandler;
ezsp.ezspUnusedPanIdFoundHandler = (panId, channel) => {
logger.debug(`ezspUnusedPanIdFoundHandler: ${JSON.stringify({ panId, channel })}`);
progressBar.stop();
clearInterval(progressInterval);
if (scanCompleted) {
scanCompleted([panId, channel]);
}
};
const scanStatus = await ezsp.ezspFindUnusedPanId(ZSpec.Utils.channelsToUInt32Mask(channels), duration);
if (scanStatus !== SLStatus.OK) {
logger.error(`Failed find unused PAN ID request with status=${SLStatus[scanStatus]}.`);
// restore zigbee-herdsman default
ezsp.ezspUnusedPanIdFoundHandler = ezspUnusedPanIdFoundHandlerOriginal;
return true;
}
progressBar.start(totalTime, 0);
const progressInterval = setInterval(() => {
progressBar.increment(500);
}, 500);
const result = await new Promise((resolve) => {
scanCompleted = resolve;
});
// restore zigbee-herdsman default
ezsp.ezspUnusedPanIdFoundHandler = ezspUnusedPanIdFoundHandlerOriginal;
// just in case
if (!result) {
logger.error("Found no suitable PAN ID and channel.");
return true;
}
const [foundPanId, foundChannel] = result;
const confirmForm = await confirm({
message: `Found suitable PAN ID=${foundPanId}, channel=${foundChannel}. Continue with these parameters?`,
default: true,
});
if (!confirmForm) {
logger.info("Onboard cancelled.");
return true;
}
const state = {
bitmask: EmberInitialSecurityBitmask.TRUST_CENTER_GLOBAL_LINK_KEY |
EmberInitialSecurityBitmask.HAVE_PRECONFIGURED_KEY |
EmberInitialSecurityBitmask.HAVE_NETWORK_KEY |
EmberInitialSecurityBitmask.TRUST_CENTER_USES_HASHED_LINK_KEY |
EmberInitialSecurityBitmask.REQUIRE_ENCRYPTED_KEY,
preconfiguredKey: { contents: randomBytes(EMBER_ENCRYPTION_KEY_SIZE) },
networkKey: { contents: randomBytes(ZSpec.DEFAULT_ENCRYPTION_KEY_SIZE) },
networkKeySequenceNumber: 0,
preconfiguredTrustCenterEui64: ZSpec.BLANK_EUI64,
};
status = await ezsp.ezspSetInitialSecurityState(state);
if (status !== SLStatus.OK) {
throw new Error(`Failed to set initial security state with status=${SLStatus[status]}.`);
}
const extended = EmberExtendedSecurityBitmask.JOINER_GLOBAL_LINK_KEY | EmberExtendedSecurityBitmask.NWK_LEAVE_REQUEST_NOT_ALLOWED;
status = await ezsp.ezspSetExtendedSecurityBitmask(extended);
if (status !== SLStatus.OK) {
throw new Error(`Failed to set extended security bitmask to ${extended} with status=${SLStatus[status]}.`);
}
status = await ezsp.ezspClearKeyTable();
if (status !== SLStatus.OK) {
logger.error(`Failed to clear key table with status=${SLStatus[status]}.`);
}
const netParams = {
panId: foundPanId,
extendedPanId: Array.from(randomBytes(ZSpec.EXTENDED_PAN_ID_SIZE)),
radioTxPower: radioTxPower,
radioChannel: foundChannel,
joinMethod: EmberJoinMethod.MAC_ASSOCIATION,
nwkManagerId: ZSpec.COORDINATOR_ADDRESS,
nwkUpdateId: 0,
channels: ZSpec.ALL_802_15_4_CHANNELS_MASK,
};
logger.info(`Forming new network with: ${JSON.stringify(netParams)}`);
status = await ezsp.ezspFormNetwork(netParams);
if (status !== SLStatus.OK) {
throw new Error(`Failed form network request with status=${SLStatus[status]}.`);
}
await waitForStackStatus(ezsp, SLStatus.NETWORK_UP);