ember-zli
Version:
Interact with EmberZNet-based adapters using zigbee-herdsman 'ember' driver
789 lines (788 loc) • 39.3 kB
JavaScript
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { checkbox, confirm, input, select } from "@inquirer/prompts";
import { Command } from "@oclif/core";
import { Presets, SingleBar } from "cli-progress";
import { Zcl, Zdo, ZSpec } from "zigbee-herdsman";
import { DEFAULT_STACK_CONFIG } from "zigbee-herdsman/dist/adapter/ember/adapter/emberAdapter.js";
import { EmberTokensManager } from "zigbee-herdsman/dist/adapter/ember/adapter/tokensManager.js";
import { EMBER_MIN_BROADCAST_ADDRESS, STACK_PROFILE_ZIGBEE_PRO } from "zigbee-herdsman/dist/adapter/ember/consts.js";
import { EmberCounterType, EmberExtendedSecurityBitmask, EmberIncomingMessageType, EmberInitialSecurityBitmask, EmberJoinMethod, EmberNodeType, EmberOutgoingMessageType, EzspNetworkScanType, EzspStatus, SLStatus, } from "zigbee-herdsman/dist/adapter/ember/enums.js";
import { BuffaloZdo } from "zigbee-herdsman/dist/zspec/zdo/buffaloZdo.js";
import { DATA_FOLDER, DEFAULT_ROUTER_SCRIPT_MJS_PATH, DEFAULT_ROUTER_TOKENS_BACKUP_PATH, logger } from "../../index.js";
import { APPLICATION_ZDO_SEQUENCE_MASK, DEFAULT_APS_OPTIONS, DEFAULT_ZDO_REQUEST_RADIUS } from "../../utils/consts.js";
import { emberFullVersion, emberNetworkConfig, emberNetworkInit, emberRegisterFixedEndpoints, emberSetConcentrator, emberStart, emberStop, waitForStackStatus, } from "../../utils/ember.js";
import { getPortConf } from "../../utils/port.js";
import { ROUTER_FIXED_ENDPOINTS } from "../../utils/router-endpoints.js";
import { browseToFile, loadStackConfig, toHex } from "../../utils/utils.js";
var RouterMenu;
(function (RouterMenu) {
RouterMenu[RouterMenu["NETWORK_JOIN"] = 0] = "NETWORK_JOIN";
RouterMenu[RouterMenu["NETWORK_REJOIN"] = 1] = "NETWORK_REJOIN";
RouterMenu[RouterMenu["NETWORK_LEAVE"] = 2] = "NETWORK_LEAVE";
RouterMenu[RouterMenu["NETWORK_INFO"] = 5] = "NETWORK_INFO";
RouterMenu[RouterMenu["TOKENS_BACKUP"] = 10] = "TOKENS_BACKUP";
RouterMenu[RouterMenu["TOKENS_RESTORE"] = 11] = "TOKENS_RESTORE";
RouterMenu[RouterMenu["TOKENS_RESET"] = 12] = "TOKENS_RESET";
RouterMenu[RouterMenu["READ_COUNTERS"] = 20] = "READ_COUNTERS";
RouterMenu[RouterMenu["SET_MANUFACTURER_CODE"] = 50] = "SET_MANUFACTURER_CODE";
RouterMenu[RouterMenu["PING_COORDINATOR"] = 90] = "PING_COORDINATOR";
RouterMenu[RouterMenu["RELOAD_EVENT_HANDLERS"] = 98] = "RELOAD_EVENT_HANDLERS";
RouterMenu[RouterMenu["RUN_SCRIPT"] = 99] = "RUN_SCRIPT";
})(RouterMenu || (RouterMenu = {}));
var RouterState;
(function (RouterState) {
RouterState[RouterState["UNKNOWN"] = 0] = "UNKNOWN";
RouterState[RouterState["NOT_JOINED"] = 1] = "NOT_JOINED";
RouterState[RouterState["RUNNING"] = 2] = "RUNNING";
})(RouterState || (RouterState = {}));
export default class Router extends Command {
static args = {};
static description = "Use a coordinator firmware as a router and interact with the joined network.";
static examples = ["<%= config.bin %> <%= command.id %>"];
static flags = {};
ezsp;
multicastTable = [];
routerState = RouterState.UNKNOWN;
customEventHandlers = {
onIncomingMessage: undefined,
onMessageSent: undefined,
onStackStatus: undefined,
onTouchlinkMessage: undefined,
onZDOResponse: undefined,
};
manufacturerCode = Zcl.ManufacturerCode.SILICON_LABORATORIES;
stackConfig = DEFAULT_STACK_CONFIG;
zdoRequestSequence = 0;
async run() {
// const {flags} = await this.parse(Router)
const portConf = await getPortConf();
logger.debug(`Using port conf: ${JSON.stringify(portConf)}`);
this.ezsp = await emberStart(portConf);
this.ezsp.on("ncpNeedsResetAndInit", (status) => {
logger.error(`Adapter needs restarting: status=${EzspStatus[status]}`);
this.exit(1);
});
this.ezsp.on("incomingMessage", this.onIncomingMessage.bind(this));
this.ezsp.on("messageSent", this.onMessageSent.bind(this));
this.ezsp.on("stackStatus", this.onStackStatus.bind(this));
this.ezsp.on("touchlinkMessage", this.onTouchlinkMessage.bind(this));
this.ezsp.on("zdoResponse", this.onZDOResponse.bind(this));
await this.loadCustomEventHandlers();
this.stackConfig = loadStackConfig();
await emberNetworkConfig(this.ezsp, this.stackConfig, this.manufacturerCode);
await emberRegisterFixedEndpoints(this.ezsp, this.multicastTable /* IN/OUT */, true);
let exit = false;
while (!exit) {
exit = await this.navigateMenu();
if (exit && this.routerState === RouterState.RUNNING) {
exit = await confirm({ message: "Router is currently running. Confirm exit?", default: false });
}
}
await emberStop(this.ezsp);
return this.exit(0);
}
async loadCustomEventHandlers() {
for (const handler in this.customEventHandlers) {
const handlerFile = join(DATA_FOLDER, `${handler}.mjs`);
if (existsSync(handlerFile)) {
try {
const importedScript = await import(pathToFileURL(handlerFile).toString());
if (typeof importedScript.default !== "function") {
throw new TypeError("Not a function.");
}
this.customEventHandlers[handler] = importedScript.default;
logger.info(`Loaded custom handler for '${handler}'.`);
}
catch (error) {
logger.error(`Failed to load custom handler for '${handler}'. ${error}`);
}
}
}
}
async menuNetworkInfo() {
if (!this.ezsp) {
logger.error("Invalid state, no EZSP layer available.");
return this.exit(1);
}
const [npStatus, nodeType, netParams] = await this.ezsp.ezspGetNetworkParameters();
if (npStatus !== SLStatus.OK) {
logger.error(`Failed to get network parameters with status=${SLStatus[npStatus]}.`);
return true;
}
const eui64 = await this.ezsp.ezspGetEui64();
const nodeId = await this.ezsp.ezspGetNodeId();
logger.info(`Node ID=${toHex(nodeId)}/${nodeId} 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(",")}`);
return false;
}
async menuNetworkJoin() {
if (!this.ezsp) {
logger.error("Invalid state, no EZSP layer available.");
return this.exit(1);
}
let status = await emberNetworkInit(this.ezsp, true);
const notJoined = status === SLStatus.NOT_JOINED;
this.setRouterState(notJoined ? RouterState.NOT_JOINED : RouterState.UNKNOWN);
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 join?" });
if (!overwrite) {
logger.info("Join cancelled.");
return false;
}
const status = await this.ezsp.ezspLeaveNetwork();
if (status !== SLStatus.OK) {
logger.error(`Failed to leave network with status=${SLStatus[status]}.`);
return true;
}
await waitForStackStatus(this.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 this.ezsp.ezspSetRadioPower(radioTxPower);
if (status !== SLStatus.OK) {
logger.error(`Failed to set transmit power to '${radioTxPower}' status=${SLStatus[status]}.`);
return true;
}
const channels = await checkbox({
choices: ZSpec.ALL_802_15_4_CHANNELS.map((c) => ({
name: c.toString(),
value: c,
checked: ZSpec.PREFERRED_802_15_4_CHANNELS.includes(c),
})),
message: "Channels to scan",
required: true,
});
const progressBar = new SingleBar({ clearOnComplete: true, format: "{bar} {percentage}% | ETA: {eta}s" }, Presets.shades_classic);
const duration = 4;
// a symbol is 16 microseconds, a scan period is 960 symbols
const totalTime = (((2 ** duration + 1) * (16 * 960)) / 1000) * channels.length;
let scanCompleted;
const joinableNetworks = [];
// NOTE: expanding zigbee-herdsman
const ezspNetworkFoundHandlerOriginal = this.ezsp.ezspNetworkFoundHandler;
const ezspScanCompleteHandlerOriginal = this.ezsp.ezspScanCompleteHandler;
this.ezsp.ezspNetworkFoundHandler = (networkFound, lastHopLqi, lastHopRssi) => {
logger.debug(`ezspNetworkFoundHandler: ${JSON.stringify({ networkFound, lastHopLqi, lastHopRssi })}`);
// don't want networks we can't join or wrong profile
if (networkFound.allowingJoin && networkFound.stackProfile === STACK_PROFILE_ZIGBEE_PRO) {
joinableNetworks.push({ networkFound, lastHopLqi, lastHopRssi });
}
};
this.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 this.ezsp.ezspStartScan(EzspNetworkScanType.ACTIVE_SCAN, ZSpec.Utils.channelsToUInt32Mask(channels), duration);
if (startScanStatus !== SLStatus.OK) {
logger.error(`Failed start scan request with status=${SLStatus[startScanStatus]}.`);
// restore zigbee-herdsman default
this.ezsp.ezspNetworkFoundHandler = ezspNetworkFoundHandlerOriginal;
this.ezsp.ezspScanCompleteHandler = ezspScanCompleteHandlerOriginal;
return true;
}
progressBar.start(totalTime, 0);
const progressInterval = setInterval(() => {
progressBar.increment(500);
}, 500);
await new Promise((resolve) => {
scanCompleted = resolve;
});
// restore zigbee-herdsman default
this.ezsp.ezspNetworkFoundHandler = ezspNetworkFoundHandlerOriginal;
this.ezsp.ezspScanCompleteHandler = ezspScanCompleteHandlerOriginal;
if (joinableNetworks.length === 0) {
logger.error("Found no network available to join.");
return false;
}
// sort network found by RSSI
joinableNetworks.sort((a, b) => b.lastHopLqi - a.lastHopLqi);
const networkChoices = [];
for (const { networkFound, lastHopLqi, lastHopRssi } of joinableNetworks) {
networkChoices.push({
name: `PAN ID: ${networkFound.panId} | Ext PAN ID: ${networkFound.extendedPanId} | ` +
`Channel: ${networkFound.channel} | LQI: ${lastHopLqi} | RSSI: ${lastHopRssi}`,
value: networkFound,
});
}
const networkToJoin = await select({ choices: networkChoices, message: "Available networks" });
const defaultLinkKey = Buffer.from(ZSpec.INTEROPERABILITY_LINK_KEY);
const state = {
bitmask: EmberInitialSecurityBitmask.TRUST_CENTER_GLOBAL_LINK_KEY |
EmberInitialSecurityBitmask.HAVE_PRECONFIGURED_KEY |
EmberInitialSecurityBitmask.NO_FRAME_COUNTER_RESET |
EmberInitialSecurityBitmask.REQUIRE_ENCRYPTED_KEY,
preconfiguredKey: { contents: defaultLinkKey },
networkKey: { contents: Buffer.alloc(16) }, // blank
networkKeySequenceNumber: 0,
preconfiguredTrustCenterEui64: ZSpec.BLANK_EUI64,
};
status = await this.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.EXT_NO_FRAME_COUNTER_RESET;
status = await this.ezsp.ezspSetExtendedSecurityBitmask(extended);
if (status !== SLStatus.OK) {
logger.error(`Failed to set extended security bitmask to ${extended} with status=${SLStatus[status]}.`);
return true;
}
status = await this.ezsp.ezspClearKeyTable();
if (status !== SLStatus.OK) {
logger.error(`Failed to clear key table with status=${SLStatus[status]}.`);
}
status = await this.ezsp.ezspImportTransientKey(ZSpec.BLANK_EUI64, { contents: defaultLinkKey });
if (status !== SLStatus.OK) {
logger.error(`Failed to import transient key with status=${SLStatus[status]}.`);
return true;
}
status = await this.ezsp.ezspJoinNetwork(EmberNodeType.ROUTER, {
extendedPanId: networkToJoin.extendedPanId,
panId: networkToJoin.panId,
radioTxPower,
radioChannel: networkToJoin.channel,
joinMethod: EmberJoinMethod.MAC_ASSOCIATION,
nwkManagerId: 0,
nwkUpdateId: networkToJoin.nwkUpdateId,
channels: ZSpec.ALL_802_15_4_CHANNELS_MASK,
});
if (status !== SLStatus.OK) {
logger.error(`Failed to join specified network with status=${SLStatus[status]}.`);
return true;
}
await waitForStackStatus(this.ezsp, SLStatus.NETWORK_UP);
await emberSetConcentrator(this.ezsp, this.stackConfig);
this.setRouterState(RouterState.RUNNING);
const permitJoining = await confirm({ default: true, message: "Permit joining to extend network?" });
if (permitJoining) {
const [status] = await this.permitJoining(180, true);
if (status !== SLStatus.OK) {
logger.error(`Failed to permit joining with status=${SLStatus[status]}.`);
}
}
return false;
}
async menuNetworkLeave() {
if (!this.ezsp) {
logger.error("Invalid state, no EZSP layer available.");
return this.exit(1);
}
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;
}
if (this.routerState !== RouterState.RUNNING) {
const initStatus = await emberNetworkInit(this.ezsp, true);
if (initStatus === SLStatus.NOT_JOINED) {
logger.info("No network present.");
return false;
}
if (initStatus !== SLStatus.OK) {
logger.error(`Failed network init request with status=${SLStatus[initStatus]}.`);
return true;
}
await waitForStackStatus(this.ezsp, SLStatus.NETWORK_UP);
// NOTE: explicitly not set since we don't want to consider this "running"
// this.setRouterState(RouterState.RUNNING)
}
const leaveStatus = await this.ezsp.ezspLeaveNetwork();
if (leaveStatus !== SLStatus.OK) {
logger.error(`Failed to leave network with status=${SLStatus[leaveStatus]}.`);
return true;
}
await waitForStackStatus(this.ezsp, SLStatus.NETWORK_DOWN);
this.setRouterState(RouterState.NOT_JOINED);
logger.info("Left network.");
return false;
}
async menuNetworkRejoin() {
if (!this.ezsp) {
logger.error("Invalid state, no EZSP layer available.");
return this.exit(1);
}
const initStatus = await emberNetworkInit(this.ezsp, true);
const notJoined = initStatus === SLStatus.NOT_JOINED;
this.setRouterState(notJoined ? RouterState.NOT_JOINED : RouterState.UNKNOWN);
if (!notJoined && initStatus !== SLStatus.OK) {
logger.error(`Failed network init request with status=${SLStatus[initStatus]}.`);
return true;
}
if (notJoined) {
logger.info("No network present in the adapter, cannot rejoin.");
return false;
}
await waitForStackStatus(this.ezsp, SLStatus.NETWORK_UP);
await emberSetConcentrator(this.ezsp, this.stackConfig);
const [npStatus, nodeType, netParams] = await this.ezsp.ezspGetNetworkParameters();
if (npStatus !== SLStatus.OK) {
logger.error(`Failed to get network parameters with status=${SLStatus[npStatus]}.`);
return true;
}
if (nodeType !== EmberNodeType.ROUTER) {
logger.error(`Current network is not router: nodeType=${EmberNodeType[nodeType]}`);
return true;
}
logger.info(`Current adapter network: ${JSON.stringify(netParams)}`);
this.setRouterState(RouterState.RUNNING);
const permitJoining = await confirm({ default: true, message: "Permit joining to extend network?" });
if (permitJoining) {
const [status] = await this.permitJoining(180, true);
if (status !== SLStatus.OK) {
logger.error(`Failed to permit joining with status=${SLStatus[status]}.`);
}
}
return false;
}
async menuPingCoordinator() {
if (!this.ezsp) {
logger.error("Invalid state, no EZSP layer available.");
return this.exit(1);
}
const [status] = await this.ezsp.send(EmberOutgoingMessageType.DIRECT, ZSpec.COORDINATOR_ADDRESS, {
profileId: ZSpec.HA_PROFILE_ID,
clusterId: Zcl.Clusters.genBasic.ID,
sourceEndpoint: ROUTER_FIXED_ENDPOINTS[0].endpoint,
destinationEndpoint: ROUTER_FIXED_ENDPOINTS[0].endpoint,
options: DEFAULT_APS_OPTIONS,
groupId: 0,
sequence: 0,
},
// type 'readResponse', cluster 'genBasic', data '{"zclVersion":8}'
Buffer.from("1801010000002008", "hex"), 0, 0);
if (status !== SLStatus.OK) {
logger.error(`Failed to ping coordinator with status=${SLStatus[status]}.`);
}
return false;
}
async menuReadCounters() {
if (!this.ezsp) {
logger.error("Invalid state, no EZSP layer available.");
return this.exit(1);
}
const alsoClear = await confirm({ message: "Clear counters after read?", default: true });
const counters = alsoClear ? await this.ezsp.ezspReadAndClearCounters() : await this.ezsp.ezspReadCounters();
for (let i = 0; i < EmberCounterType.COUNT; i++) {
logger.info(`Counter ${EmberCounterType[i]}=${counters[i]}`);
}
return false;
}
async menuReloadEventHandlers() {
await this.loadCustomEventHandlers();
return false;
}
async menuRunScript() {
const jsFile = await browseToFile("File to run", DEFAULT_ROUTER_SCRIPT_MJS_PATH);
try {
const scriptToRun = await import(pathToFileURL(jsFile).toString());
scriptToRun.default(this, logger);
}
catch (error) {
logger.error(error);
}
return false;
}
async menuSetManufacturerCode() {
if (!this.ezsp) {
logger.error("Invalid state, no EZSP layer available.");
return this.exit(1);
}
let Source;
(function (Source) {
Source[Source["ZCL_LIST"] = 0] = "ZCL_LIST";
Source[Source["INPUT"] = 1] = "INPUT";
})(Source || (Source = {}));
const source = await select({
choices: [
{ name: "From ZCL list (long)", value: Source.ZCL_LIST },
{ name: "From manual input", value: Source.INPUT },
{ name: "Go Back", value: -1 },
],
message: "Source for the manufacturer code",
});
let newCode = this.manufacturerCode;
switch (source) {
case Source.ZCL_LIST: {
newCode = await select({
choices: Object.keys(Zcl.ManufacturerCode).map((k) => ({
name: k,
value: Zcl.ManufacturerCode[k],
})),
message: "Select manufacturer",
});
break;
}
case Source.INPUT: {
newCode = Number.parseInt(await input({
default: Zcl.ManufacturerCode.SILICON_LABORATORIES.toString(),
message: "Code [0-65535/0x0000-0xFFFF]",
validate(value) {
if (/\./.test(value)) {
return false;
}
const v = Number.parseInt(value, value.startsWith("0x") ? 16 : 10);
return v >= 0 && v <= 65535;
},
}), 10);
break;
}
case -1: {
return false;
}
}
this.manufacturerCode = newCode;
await this.ezsp.ezspSetManufacturerCode(newCode);
return false;
}
async menuTokensBackup() {
if (!this.ezsp) {
logger.error("Invalid state, no EZSP layer available.");
return this.exit(1);
}
const saveFile = await browseToFile("Router tokens backup save file", DEFAULT_ROUTER_TOKENS_BACKUP_PATH, true);
const eui64 = await this.ezsp.ezspGetEui64();
const tokensBuf = await EmberTokensManager.saveTokens(this.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() {
if (!this.ezsp) {
logger.error("Invalid state, no EZSP layer available.");
return this.exit(1);
}
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: [
{ name: "Exclude network and APS outgoing frame counter tokens?", value: "excludeOutgoingFC", checked: false },
{ name: "Exclude stack boot counter token?", value: "excludeBootCounter", checked: false },
],
message: "Reset options",
});
await this.ezsp.ezspTokenFactoryReset(options.includes("excludeOutgoingFC"), options.includes("excludeBootCounter"));
return true;
}
async menuTokensRestore() {
if (!this.ezsp) {
logger.error("Invalid state, no EZSP layer available.");
return this.exit(1);
}
const backupFile = await browseToFile("Router tokens backup file location", DEFAULT_ROUTER_TOKENS_BACKUP_PATH);
const tokensBuf = Buffer.from(readFileSync(backupFile, "utf8"), "hex");
const status = await EmberTokensManager.restoreTokens(this.ezsp, tokensBuf);
if (status === SLStatus.OK) {
logger.info("Restored router tokens.");
}
else {
logger.error("Failed to restore router tokens.");
}
return true;
}
async navigateMenu() {
const notRunning = this.routerState !== RouterState.RUNNING;
const answer = await select({
choices: [
{ name: "Join network", value: RouterMenu.NETWORK_JOIN },
{
name: "Rejoin network",
value: RouterMenu.NETWORK_REJOIN,
disabled: this.routerState === RouterState.NOT_JOINED || this.routerState !== RouterState.UNKNOWN,
},
{ name: "Leave network", value: RouterMenu.NETWORK_LEAVE },
{ name: "Backup NVM3 tokens", value: RouterMenu.TOKENS_BACKUP },
{ name: "Restore NVM3 tokens", value: RouterMenu.TOKENS_RESTORE },
{ name: "Reset NVM3 tokens", value: RouterMenu.TOKENS_RESET },
{ name: "Get network info", value: RouterMenu.NETWORK_INFO, disabled: notRunning },
{ name: "Set manufacturer code", value: RouterMenu.SET_MANUFACTURER_CODE, disabled: notRunning },
{ name: "Read counters", value: RouterMenu.PING_COORDINATOR, disabled: notRunning },
{ name: "Ping coordinator", value: RouterMenu.READ_COUNTERS, disabled: notRunning },
{ name: "Reload custom event handlers", value: RouterMenu.RELOAD_EVENT_HANDLERS },
{ name: "Run custom script", value: RouterMenu.RUN_SCRIPT, disabled: notRunning },
{ name: "Exit", value: -1 },
],
message: "Menu",
});
switch (answer) {
case RouterMenu.NETWORK_JOIN: {
return await this.menuNetworkJoin();
}
case RouterMenu.NETWORK_REJOIN: {
return await this.menuNetworkRejoin();
}
case RouterMenu.NETWORK_LEAVE: {
return await this.menuNetworkLeave();
}
case RouterMenu.TOKENS_BACKUP: {
return await this.menuTokensBackup();
}
case RouterMenu.TOKENS_RESTORE: {
return await this.menuTokensRestore();
}
case RouterMenu.TOKENS_RESET: {
return await this.menuTokensReset();
}
case RouterMenu.NETWORK_INFO: {
return await this.menuNetworkInfo();
}
case RouterMenu.SET_MANUFACTURER_CODE: {
return await this.menuSetManufacturerCode();
}
case RouterMenu.READ_COUNTERS: {
return await this.menuReadCounters();
}
case RouterMenu.PING_COORDINATOR: {
return await this.menuPingCoordinator();
}
case RouterMenu.RELOAD_EVENT_HANDLERS: {
return await this.menuReloadEventHandlers();
}
case RouterMenu.RUN_SCRIPT: {
return await this.menuRunScript();
}
}
return true; // exit
}
async onIncomingMessage(type, apsFrame, lastHopLqi, sender, messageContents) {
if (sender === ZSpec.COORDINATOR_ADDRESS &&
type === EmberIncomingMessageType.UNICAST &&
apsFrame.profileId === ZSpec.HA_PROFILE_ID &&
apsFrame.clusterId === Zcl.Clusters.genBasic.ID &&
apsFrame.destinationEndpoint === ROUTER_FIXED_ENDPOINTS[0].endpoint &&
apsFrame.sourceEndpoint === ROUTER_FIXED_ENDPOINTS[0].endpoint) {
const header = Zcl.Header.fromBuffer(messageContents);
if (header?.isGlobal &&
header.frameControl.direction === Zcl.Direction.CLIENT_TO_SERVER &&
header.commandIdentifier === Zcl.Foundation.read.ID) {
// handle replying to Z2M interview + ping attribute reads
const frame = Zcl.Frame.fromBuffer(apsFrame.clusterId, header, messageContents, {});
const replyPayload = {
attrId: frame.payload[0].attrId,
status: Zcl.Status.SUCCESS,
dataType: undefined,
attrData: undefined,
};
switch (replyPayload.attrId) {
case Zcl.Clusters.genBasic.attributes.zclVersion.ID: {
replyPayload.dataType = Zcl.Clusters.genBasic.attributes.zclVersion.type;
replyPayload.attrData = 8; // DataType.UINT8
break;
}
case Zcl.Clusters.genBasic.attributes.appVersion.ID: {
replyPayload.dataType = Zcl.Clusters.genBasic.attributes.appVersion.type;
replyPayload.attrData = emberFullVersion.ezsp; // DataType.UINT8
break;
}
case Zcl.Clusters.genBasic.attributes.stackVersion.ID: {
replyPayload.dataType = Zcl.Clusters.genBasic.attributes.stackVersion.type;
replyPayload.attrData = emberFullVersion.major; // DataType.UINT8
break;
}
case Zcl.Clusters.genBasic.attributes.manufacturerName.ID: {
replyPayload.dataType = Zcl.Clusters.genBasic.attributes.manufacturerName.type;
replyPayload.attrData = Zcl.ManufacturerCode[this.manufacturerCode]; // DataType.CHAR_STR
break;
}
case Zcl.Clusters.genBasic.attributes.modelId.ID: {
replyPayload.dataType = Zcl.Clusters.genBasic.attributes.modelId.type;
replyPayload.attrData = "Ember ZLI Router"; // DataType.CHAR_STR
break;
}
case Zcl.Clusters.genBasic.attributes.dateCode.ID: {
replyPayload.dataType = Zcl.Clusters.genBasic.attributes.dateCode.type;
replyPayload.attrData = emberFullVersion.revision; // DataType.CHAR_STR
break;
}
case Zcl.Clusters.genBasic.attributes.powerSource.ID: {
replyPayload.dataType = Zcl.Clusters.genBasic.attributes.powerSource.type;
// Mains
replyPayload.attrData = 1; // DataType.ENUM8
break;
}
case Zcl.Clusters.genBasic.attributes.swBuildId.ID: {
replyPayload.dataType = Zcl.Clusters.genBasic.attributes.swBuildId.type;
replyPayload.attrData = emberFullVersion.revision; // DataType.CHAR_STR
break;
}
}
if (replyPayload.dataType !== undefined && replyPayload.attrData !== undefined) {
const zclFrame = Zcl.Frame.create(header.frameControl.frameType, Zcl.Direction.SERVER_TO_CLIENT, true, this.manufacturerCode, header.transactionSequenceNumber, Zcl.Foundation.readRsp.ID, Zcl.Clusters.genBasic.ID, [replyPayload], // repetitive strategy, wrap in array
{});
logger.debug(`~~~> [ZCL to=${ZSpec.COORDINATOR_ADDRESS} apsFrame=${JSON.stringify(apsFrame)} header=${JSON.stringify(zclFrame.header)}]`);
try {
await this.ezsp.send(EmberOutgoingMessageType.DIRECT, ZSpec.COORDINATOR_ADDRESS, apsFrame, zclFrame.toBuffer(), 0, 0);
}
catch (error) {
logger.debug(error);
}
}
}
}
if (this.customEventHandlers.onIncomingMessage) {
await this.customEventHandlers.onIncomingMessage(this, logger, type, apsFrame, lastHopLqi, sender, messageContents);
}
}
async onMessageSent(status, type, indexOrDestination, apsFrame, messageTag) {
switch (status) {
case SLStatus.ZIGBEE_DELIVERY_FAILED: {
// no ACK was received from the destination
logger.error(`Delivery of ${EmberOutgoingMessageType[type]} failed for '${indexOrDestination}' [apsFrame=${JSON.stringify(apsFrame)} messageTag=${messageTag}]`);
break;
}
case SLStatus.OK: {
if (type === EmberOutgoingMessageType.MULTICAST &&
apsFrame.destinationEndpoint === 0xff &&
apsFrame.groupId < EMBER_MIN_BROADCAST_ADDRESS &&
!this.multicastTable.includes(apsFrame.groupId)) {
// workaround for devices using multicast for state update (coordinator passthrough)
const tableIdx = this.multicastTable.length;
const multicastEntry = {
multicastId: apsFrame.groupId,
endpoint: ROUTER_FIXED_ENDPOINTS[0].endpoint,
networkIndex: ROUTER_FIXED_ENDPOINTS[0].networkIndex,
};
// set immediately to avoid potential race
this.multicastTable.push(multicastEntry.multicastId);
try {
const status = await this.ezsp.ezspSetMulticastTableEntry(tableIdx, multicastEntry);
if (status !== SLStatus.OK) {
throw new Error(`Failed to register group '${multicastEntry.multicastId}' in multicast table with status=${SLStatus[status]}.`);
}
logger.debug(`Registered multicast table entry (${tableIdx}): ${JSON.stringify(multicastEntry)}.`);
}
catch (error) {
// remove to allow retry on next occurrence
this.multicastTable.splice(tableIdx, 1);
logger.error(`${error}`);
}
}
break;
}
}
// shouldn't be any other status
if (this.customEventHandlers.onMessageSent) {
await this.customEventHandlers.onMessageSent(this, logger, status, type, indexOrDestination, apsFrame, messageTag);
}
}
async onStackStatus(status) {
if (status === SLStatus.NETWORK_DOWN) {
this.setRouterState(RouterState.NOT_JOINED);
}
if (this.customEventHandlers.onStackStatus) {
await this.customEventHandlers.onStackStatus(this, logger, status);
}
}
async onTouchlinkMessage(sourcePanId, sourceAddress, groupId, lastHopLqi, messageContents) {
if (this.customEventHandlers.onTouchlinkMessage) {
await this.customEventHandlers.onTouchlinkMessage(this, logger, sourcePanId, sourceAddress, groupId, lastHopLqi, messageContents);
}
}
async onZDOResponse(apsFrame, sender, messageContents) {
if (this.customEventHandlers.onZDOResponse) {
await this.customEventHandlers.onZDOResponse(this, logger, apsFrame, sender, messageContents);
}
}
async permitJoining(duration, broadcastMgmtPermitJoin) {
if (!this.ezsp) {
logger.error("Invalid state, no EZSP layer available.");
return this.exit(1);
}
let status = await this.ezsp.ezspPermitJoining(duration);
let apsFrame;
let messageTag;
logger.debug(`Permit joining for ${duration} sec. status=${[status]}`);
if (broadcastMgmtPermitJoin) {
// `authentication`: TC significance always 1 (zb specs)
const zdoPayload = BuffaloZdo.buildRequest(true, Zdo.ClusterId.PERMIT_JOINING_REQUEST, duration, 1, []);
[status, apsFrame, messageTag] = await this.sendZDORequest(ZSpec.BroadcastAddress.DEFAULT, Zdo.ClusterId.PERMIT_JOINING_REQUEST, zdoPayload, DEFAULT_APS_OPTIONS);
}
return [status, apsFrame, messageTag];
}
async sendZDORequest(destination, clusterId, messageContents, options) {
if (!this.ezsp) {
logger.error("Invalid state, no EZSP layer available.");
return this.exit(1);
}
this.zdoRequestSequence = ++this.zdoRequestSequence & APPLICATION_ZDO_SEQUENCE_MASK;
const messageTag = this.zdoRequestSequence;
messageContents[0] = messageTag;
const apsFrame = {
profileId: Zdo.ZDO_PROFILE_ID,
clusterId,
sourceEndpoint: Zdo.ZDO_ENDPOINT,
destinationEndpoint: Zdo.ZDO_ENDPOINT,
options,
groupId: 0,
sequence: 0, // set by stack
};
if (destination === ZSpec.BroadcastAddress.DEFAULT ||
destination === ZSpec.BroadcastAddress.RX_ON_WHEN_IDLE ||
destination === ZSpec.BroadcastAddress.SLEEPY) {
logger.debug(`~~~> [ZDO ${Zdo.ClusterId[clusterId]} BROADCAST to=${destination} messageTag=${messageTag} ` +
`messageContents=${messageContents.toString("hex")}]`);
const [status, apsSequence] = await this.ezsp.ezspSendBroadcast(ZSpec.NULL_NODE_ID, // alias
destination, 0, // nwkSequence
apsFrame, DEFAULT_ZDO_REQUEST_RADIUS, messageTag, messageContents);
apsFrame.sequence = apsSequence;
logger.debug(`~~~> [SENT ZDO type=BROADCAST apsSequence=${apsSequence} messageTag=${messageTag} status=${SLStatus[status]}`);
return [status, apsFrame, messageTag];
}
logger.debug(`~~~> [ZDO ${Zdo.ClusterId[clusterId]} UNICAST to=${destination} messageTag=${messageTag} ` +
`messageContents=${messageContents.toString("hex")}]`);
const [status, apsSequence] = await this.ezsp.ezspSendUnicast(EmberOutgoingMessageType.DIRECT, destination, apsFrame, messageTag, messageContents);
apsFrame.sequence = apsSequence;
logger.debug(`~~~> [SENT ZDO type=DIRECT apsSequence=${apsSequence} messageTag=${messageTag} status=${SLStatus[status]}`);
return [status, apsFrame, messageTag];
}
setRouterState(newState) {
if (newState === this.routerState) {
return;
}
logger.info(`Router state changed: previous=${RouterState[this.routerState]} new=${RouterState[newState]}.`);
this.routerState = newState;
}
}