UNPKG

ember-zli

Version:

Interact with EmberZNet-based adapters using zigbee-herdsman 'ember' driver

585 lines (584 loc) 26 kB
import EventEmitter from "node:events"; import { crc32 } from "node:zlib"; import { confirm, select } from "@inquirer/prompts"; import { SLStatus } from "zigbee-herdsman/dist/adapter/ember/enums.js"; import { logger } from "../index.js"; import { TCP_REGEX } from "./consts.js"; import { Cpc, CpcEvent } from "./cpc.js"; import { emberStart, emberStop } from "./ember.js"; import { CpcSystemStatus, FirmwareValidation } from "./enums.js"; import { MinimalSpinel } from "./spinel.js"; import { Transport, TransportEvent } from "./transport.js"; import { XEvent, XModemCRC } from "./xmodem.js"; const NS = { namespace: "gecko" }; export var BootloaderState; (function (BootloaderState) { /** Not connected to bootloader (i.e. not any of below) */ BootloaderState[BootloaderState["NOT_CONNECTED"] = 0] = "NOT_CONNECTED"; /** Waiting in menu */ BootloaderState[BootloaderState["IDLE"] = 1] = "IDLE"; /** Triggered 'Upload GBL' menu */ BootloaderState[BootloaderState["BEGIN_UPLOAD"] = 2] = "BEGIN_UPLOAD"; /** Received 'begin upload' */ BootloaderState[BootloaderState["UPLOADING"] = 3] = "UPLOADING"; /** GBL upload completed */ BootloaderState[BootloaderState["UPLOADED"] = 4] = "UPLOADED"; /** Triggered 'Run' menu */ BootloaderState[BootloaderState["RUNNING"] = 5] = "RUNNING"; /** Triggered 'EBL Info' menu */ BootloaderState[BootloaderState["GETTING_INFO"] = 6] = "GETTING_INFO"; /** Received response for 'EBL Info' menu */ BootloaderState[BootloaderState["GOT_INFO"] = 7] = "GOT_INFO"; })(BootloaderState || (BootloaderState = {})); export var BootloaderMenu; (function (BootloaderMenu) { BootloaderMenu[BootloaderMenu["UPLOAD_GBL"] = 49] = "UPLOAD_GBL"; BootloaderMenu[BootloaderMenu["RUN"] = 50] = "RUN"; BootloaderMenu[BootloaderMenu["INFO"] = 51] = "INFO"; BootloaderMenu[BootloaderMenu["CLEAR_APP"] = 254] = "CLEAR_APP"; BootloaderMenu[BootloaderMenu["CLEAR_NVM3"] = 255] = "CLEAR_NVM3"; })(BootloaderMenu || (BootloaderMenu = {})); const CARRIAGE_RETURN = 0x0d; const NEWLINE = 0x0a; const BOOTLOADER_KNOCK = Buffer.from([NEWLINE]); const BOOTLOADER_MENU_UPLOAD_GBL = Buffer.from([BootloaderMenu.UPLOAD_GBL]); const BOOTLOADER_MENU_RUN = Buffer.from([BootloaderMenu.RUN]); const BOOTLOADER_MENU_INFO = Buffer.from([BootloaderMenu.INFO]); const BOOTLOADER_PROMPT = Buffer.from("BL >", "ascii"); const BOOTLOADER_VERSION = Buffer.from("Bootloader v", "ascii"); const BOOTLOADER_BEGIN_UPLOAD = Buffer.from("begin upload", "ascii"); const BOOTLOADER_UPLOAD_COMPLETE = Buffer.from("Serial upload complete", "ascii"); const BOOTLOADER_UPLOAD_ABORTED = Buffer.from("Serial upload aborted", "ascii"); /** * End of RSTACK frame * - `1ac102092a107e` * - CANCEL, RSTACK, version, RESET_BOOTLOADER, CRC, CRC, FLAG) */ const BOOTLOADER_FIRMWARE_RAN = Buffer.from("~", "ascii"); const BOOTLOADER_KNOCK_TIMEOUT = 1500; const BOOTLOADER_UPLOAD_TIMEOUT = 1800000; const BOOTLOADER_UPLOAD_EXIT_TIMEOUT = 1500; const BOOTLOADER_CMD_EXEC_TIMEOUT = 500; const BOOTLOADER_RUN_TIMEOUT = 2000; const GBL_START_TAG = Buffer.from([0xeb, 0x17, 0xa6, 0x03]); /** Contains length+CRC32 and possibly padding after this. */ const GBL_END_TAG = Buffer.from([0xfc, 0x04, 0x04, 0xfc]); const GBL_METADATA_TAG = Buffer.from([0xf6, 0x08, 0x08, 0xf6]); const VALID_FIRMWARE_CRC32 = 558161692; const SUPPORTED_VERSIONS_REGEX = /(7\.4\.\d\.\d)|(8\.[0-2]\.\d\.\d)/; const FORCE_RESET_SUPPORT_ADAPTERS = ["Sonoff ZBDongle-E", "ROUTER - Sonoff ZBDongle-E"]; const ALWAYS_FORCE_RESET_ADAPTERS = ["ROUTER - Sonoff ZBDongle-E"]; export var BootloaderEvent; (function (BootloaderEvent) { BootloaderEvent["FAILED"] = "failed"; BootloaderEvent["CLOSED"] = "closed"; BootloaderEvent["UPLOAD_START"] = "uploadStart"; BootloaderEvent["UPLOAD_STOP"] = "uploadStop"; BootloaderEvent["UPLOAD_PROGRESS"] = "uploadProgress"; })(BootloaderEvent || (BootloaderEvent = {})); var FirmwareType; (function (FirmwareType) { FirmwareType[FirmwareType["EMBERZNET_NCP"] = 0] = "EMBERZNET_NCP"; FirmwareType[FirmwareType["OPENTHREAD_RCP"] = 1] = "OPENTHREAD_RCP"; FirmwareType[FirmwareType["MULTIPROTOCOL_RCP"] = 2] = "MULTIPROTOCOL_RCP"; })(FirmwareType || (FirmwareType = {})); export class GeckoBootloader extends EventEmitter { adapterModel; portConf; transport; xmodem; state; waiter; constructor(portConf, adapterModel) { super(); this.state = BootloaderState.NOT_CONNECTED; this.waiter = undefined; this.portConf = portConf; this.adapterModel = adapterModel; // override config to default for serial gecko bootloader this.transport = new Transport({ ...this.portConf, baudRate: 115200, rtscts: false, xon: false, xoff: false, }); this.xmodem = new XModemCRC(); this.transport.on(TransportEvent.FAILED, this.onTransportFailed.bind(this)); this.transport.on(TransportEvent.DATA, this.onTransportData.bind(this)); this.xmodem.on(XEvent.START, this.onXModemStart.bind(this)); this.xmodem.on(XEvent.STOP, this.onXModemStop.bind(this)); this.xmodem.on(XEvent.DATA, this.onXModemData.bind(this)); } async connect() { if (this.state !== BootloaderState.NOT_CONNECTED) { logger.debug("Already connected to bootloader. Skipping connect attempt.", NS); return; } logger.info("Connecting to bootloader...", NS); // check if already in bootloader, don't fail if not successful await this.knock(false); // @ts-expect-error changed by received serial data if (this.state !== BootloaderState.IDLE) { // not already in bootloader, so launch it, then knock again const currentFirmwareType = await select({ choices: [ { name: "EmberZNet NCP (a.k.a. zigbee_ncp, ncp-uart-hw, EZSP NCP)", value: FirmwareType.EMBERZNET_NCP }, { name: "OpenThread RCP (a.k.a. openthread_rcp, ot-rcp)", value: FirmwareType.OPENTHREAD_RCP }, { name: "Multiprotocol RCP (a.k.a. rcp-uart-802154)", value: FirmwareType.MULTIPROTOCOL_RCP }, ], message: "Currently installed firmware", }); switch (currentFirmwareType) { case FirmwareType.EMBERZNET_NCP: { await this.ezspLaunch(); break; } case FirmwareType.OPENTHREAD_RCP: { await this.spinelLaunch(); break; } case FirmwareType.MULTIPROTOCOL_RCP: { await this.cpcLaunch(); break; } } // this time will fail if not successful since exhausted all possible ways await this.knock(true); // @ts-expect-error changed by received serial data if (this.state !== BootloaderState.IDLE) { logger.error("Failed to enter bootloader menu.", NS); this.emit(BootloaderEvent.FAILED); return; } } logger.info("Connected to bootloader.", NS); } async navigate(menu, firmware) { this.waiter = undefined; this.state = BootloaderState.IDLE; switch (menu) { case BootloaderMenu.UPLOAD_GBL: { if (firmware === undefined) { logger.error("Navigating to upload GBL requires a valid firmware.", NS); await this.transport.close(false); // don't emit closed since we're returning true which will close anyway return true; } return await this.menuUploadGBL(firmware); } case BootloaderMenu.RUN: { return await this.menuRun(); } case BootloaderMenu.INFO: { return await this.menuGetInfo(); } case BootloaderMenu.CLEAR_APP: { if (firmware === undefined) { logger.error("Navigating to clear APP requires a valid firmware.", NS); await this.transport.close(false); // don't emit closed since we're returning true which will close anyway return true; } const confirmed = await confirm({ default: false, message: "Confirm APP clearing? (Cannot be undone; will erase the entire firmware (including NVM3). You MUST flash a new one afterwards.)", }); if (!confirmed) { logger.warning("Cancelled APP clearing.", NS); return false; } return await this.menuUploadGBL(firmware); } case BootloaderMenu.CLEAR_NVM3: { if (firmware === undefined) { logger.error("Navigating to clear NVM3 requires a valid firmware.", NS); await this.transport.close(false); // don't emit closed since we're returning true which will close anyway return true; } const confirmed = await confirm({ default: false, message: "Confirm NVM3 clearing? (Cannot be undone; will reset the adapter to factory defaults.)", }); if (!confirmed) { logger.warning("Cancelled NVM3 clearing.", NS); return false; } return await this.menuUploadGBL(firmware); } } } async forceReset(exit) { switch (this.adapterModel) { // TODO: support per adapter case "Sonoff ZBDongle-E": case "ROUTER - Sonoff ZBDongle-E": { await this.transport.serialSet({ dtr: false, rts: true }); await this.transport.serialSet({ dtr: true, rts: false }, 100); if (exit) { await this.transport.serialSet({ dtr: false }, 500); } break; } default: { logger.debug(`Reset by pattern unavailable for ${this.adapterModel}.`, NS); } } } async validateFirmware(firmware) { if (!firmware) { logger.error("Cannot proceed without a firmware file.", NS); return FirmwareValidation.INVALID; } if (firmware.indexOf(GBL_START_TAG) !== 0) { logger.error("Firmware file invalid. GBL start tag not found.", NS); return FirmwareValidation.INVALID; } const endTagStart = firmware.lastIndexOf(GBL_END_TAG); if (endTagStart === -1) { logger.error("Firmware file invalid. GBL end tag not found.", NS); return FirmwareValidation.INVALID; } const computedCRC32 = crc32(firmware.subarray(0, endTagStart + 12), 0); // tag+length+crc32 (4+4+4) if (computedCRC32 !== VALID_FIRMWARE_CRC32) { logger.error(`Firmware file invalid. Failed CRC validation (got ${computedCRC32}, expected ${VALID_FIRMWARE_CRC32}).`, NS); return FirmwareValidation.INVALID; } const metaTagStart = firmware.lastIndexOf(GBL_METADATA_TAG); if (metaTagStart === -1) { const proceed = await confirm({ default: false, message: "Firmware file does not contain metadata. Cannot validate it. Proceed with this firmware?", }); if (!proceed) { logger.warning("Cancelling firmware update.", NS); return FirmwareValidation.CANCELLED; } return FirmwareValidation.VALID; } const metaTagLength = firmware.readUInt32LE(metaTagStart + GBL_METADATA_TAG.length); const metaStart = metaTagStart + GBL_METADATA_TAG.length + 4; const metaEnd = metaStart + metaTagLength; const metaBuf = firmware.subarray(metaStart, metaEnd); logger.debug(`Metadata: tagStart=${metaTagStart}, tagLength=${metaTagLength}, start=${metaStart}, end=${metaEnd}, data=${metaBuf.toString("hex")}`, NS); try { const recdMetadata = JSON.parse(metaBuf.toString("utf8")); logger.info(`Firmware file metadata: ${JSON.stringify(recdMetadata)}`, NS); // checks irrelevant for router firmware if (!recdMetadata.fw_type.includes("router")) { if (!TCP_REGEX.test(this.portConf.path) && recdMetadata.baudrate !== this.portConf.baudRate) { logger.warning(`Firmware file baudrate ${recdMetadata.baudrate} differs from your current port configuration of ${this.portConf.baudRate}.`, NS); } if (!recdMetadata.ezsp_version || !SUPPORTED_VERSIONS_REGEX.test(recdMetadata.ezsp_version)) { logger.warning("Firmware file version is not recognized as currently supported by Zigbee2MQTT ember driver.", NS); } } const proceed = await confirm({ default: false, message: `Version: ${recdMetadata.ezsp_version}, Baudrate: ${recdMetadata.baudrate}. Proceed with this firmware?`, }); if (!proceed) { logger.warning("Cancelling firmware update.", NS); return FirmwareValidation.CANCELLED; } } catch (error) { logger.error(`Failed to validate firmware file: ${error}.`, NS); return FirmwareValidation.INVALID; } return FirmwareValidation.VALID; } async cpcLaunch() { logger.debug("Launching bootloader from CPC...", NS); const cpc = new Cpc(this.portConf); await cpc.start(); cpc.on(CpcEvent.FAILED, this.onTransportFailed.bind(this)); try { const status = await cpc.cpcLaunchStandaloneBootloader(); if (status !== CpcSystemStatus.OK) { throw new Error(CpcSystemStatus[status]); } } catch (error) { logger.error(`Unable to launch bootloader from CPC: ${error}`, NS); this.emit(BootloaderEvent.FAILED); return; } await cpc.stop(); } async ezspLaunch() { logger.debug("Launching bootloader from EZSP...", NS); const ezsp = await emberStart(this.portConf); try { const status = await ezsp.ezspLaunchStandaloneBootloader(true); if (status !== SLStatus.OK) { throw new Error(SLStatus[status]); } } catch (error) { logger.error(`Unable to launch bootloader from EZSP: ${error}`, NS); this.emit(BootloaderEvent.FAILED); return; } // free serial await emberStop(ezsp); } async spinelLaunch() { logger.debug("Launching bootloader from Spinel...", NS); const spinel = new MinimalSpinel(this.portConf); await spinel.start(); try { await spinel.driver.resetIntoBootloader(); await new Promise((resolve) => setTimeout(resolve, 200)); } catch (error) { logger.error(`Unable to launch bootloader from Spinel: ${error}`, NS); this.emit(BootloaderEvent.FAILED); return; } // free serial await spinel.stop(); } async knock(fail) { logger.info(fail ? "Entering bootloader..." : "Trying to enter bootloader...", NS); try { await this.transport.initPort(); // try force reset if supported (only on initial non-fail knock) if (!fail && this.adapterModel && FORCE_RESET_SUPPORT_ADAPTERS.includes(this.adapterModel)) { // XXX: always force reset Sonoff ZBDongle-E Router to prevent issues with EZSP 6.10.3 (can be removed once versions updated and no longer used) const forceReset = ALWAYS_FORCE_RESET_ADAPTERS.includes(this.adapterModel) || (await confirm({ message: "Force reset into bootloader?", default: true })); if (forceReset) { logger.debug("Entering bootloader via force reset...", NS); await this.forceReset(false); if (this.state === BootloaderState.IDLE) { // nothing else to do if already got the bl prompt return; } } } } catch (error) { logger.error(`Failed to open port: ${error}.`, NS); await this.transport.close(false, false); // force failed below this.emit(BootloaderEvent.FAILED); return; } let res = false; for (let i = 1; i < 3; i++) { this.transport.write(BOOTLOADER_KNOCK); res = await this.waitForState(BootloaderState.IDLE, BOOTLOADER_KNOCK_TIMEOUT, fail && i === 2); if (res) { break; } if (i === 1 && this.transport.isSerial) { // if failed first attempt, try second time with RTS/CTS enabled await this.transport.serialSet({ rts: true, cts: true }); } } if (!res) { await this.transport.close(fail); // emit closed based on if we want to fail on unsuccessful knock if (fail) { logger.error("Unable to enter bootloader.", NS); } else { logger.info("Unable to enter bootloader.", NS); } } } async menuGetInfo() { logger.debug(`Entering 'Info' menu...`, NS); this.state = BootloaderState.GETTING_INFO; this.transport.write(BOOTLOADER_MENU_INFO); await this.waitForState(BootloaderState.GOT_INFO, BOOTLOADER_CMD_EXEC_TIMEOUT); return false; } async menuRun() { logger.debug(`Entering 'Run' menu...`, NS); this.state = BootloaderState.RUNNING; this.transport.write(BOOTLOADER_MENU_RUN); // this is expected to fail (signals the firmware ran and bootloader was exited) const res = await this.waitForState(BootloaderState.IDLE, BOOTLOADER_RUN_TIMEOUT, false, true); if (res) { // got menu back, failed to run logger.warning("Failed to exit bootloader and run firmware.", NS); if (this.adapterModel && FORCE_RESET_SUPPORT_ADAPTERS.includes(this.adapterModel)) { logger.warning("Trying force reset...", NS); await this.forceReset(true); } else { logger.warning("You may need to unplug/replug your adapter to run the firmware.", NS); } } else { // @ts-expect-error changed by received serial data if (this.state === BootloaderState.NOT_CONNECTED) { logger.info("Firmware ran, bootloader exited.", NS); } else { logger.info("Bootloader considered exited.", NS); } } return true; } async menuUploadGBL(firmware) { logger.debug(`Entering 'Upload GBL' menu...`, NS); this.xmodem.init(firmware); this.state = BootloaderState.BEGIN_UPLOAD; this.transport.write(BOOTLOADER_MENU_UPLOAD_GBL); // start upload await this.waitForState(BootloaderState.UPLOADING, BOOTLOADER_UPLOAD_EXIT_TIMEOUT); await this.waitForState(BootloaderState.UPLOADED, BOOTLOADER_UPLOAD_TIMEOUT); const res = await this.waitForState(BootloaderState.IDLE, BOOTLOADER_UPLOAD_EXIT_TIMEOUT, false); if (!res) { // force back to menu if not automatically back to it already this.transport.write(BOOTLOADER_KNOCK); await this.waitForState(BootloaderState.IDLE, BOOTLOADER_UPLOAD_EXIT_TIMEOUT); } return false; } async onTransportData(received) { logger.debug(`Received transport data: ${received.toString("hex")} while in state ${BootloaderState[this.state]}.`, NS); switch (this.state) { case BootloaderState.NOT_CONNECTED: { if (received.includes(BOOTLOADER_PROMPT)) { this.resolveState(BootloaderState.IDLE); } break; } case BootloaderState.IDLE: { break; } case BootloaderState.BEGIN_UPLOAD: { if (received.includes(BOOTLOADER_BEGIN_UPLOAD)) { this.resolveState(BootloaderState.UPLOADING); } break; } case BootloaderState.UPLOADING: { // just hand over to xmodem return this.xmodem.process(received); } case BootloaderState.UPLOADED: { if (received.includes(BOOTLOADER_UPLOAD_ABORTED)) { logger.error("Firmware upload aborted.", NS); } else if (received.includes(BOOTLOADER_UPLOAD_COMPLETE)) { logger.info("Firmware upload completed.", NS); } // always check if got back prompt already (can be in same tx as above) if (received.includes(BOOTLOADER_PROMPT)) { this.resolveState(BootloaderState.IDLE); } break; } case BootloaderState.RUNNING: { const blv = received.indexOf(BOOTLOADER_VERSION); if (blv !== -1) { const [blInfo] = this.readBootloaderInfo(received, blv); logger.info(`Received bootloader info while trying to exit: ${blInfo}.`, NS); } else if (received.includes(BOOTLOADER_PROMPT)) { this.resolveState(BootloaderState.IDLE); } else if (received.includes(BOOTLOADER_FIRMWARE_RAN)) { this.resolveState(BootloaderState.NOT_CONNECTED); } else { logger.debug(received.toString("ascii"), NS); } break; } case BootloaderState.GETTING_INFO: { const blv = received.indexOf(BOOTLOADER_VERSION); if (blv !== -1) { this.resolveState(BootloaderState.GOT_INFO); const [blInfo] = this.readBootloaderInfo(received, blv); logger.info(`${blInfo}.`, NS); } break; } case BootloaderState.GOT_INFO: { if (received.includes(BOOTLOADER_PROMPT)) { this.resolveState(BootloaderState.IDLE); } break; } } } onTransportFailed() { this.state = BootloaderState.NOT_CONNECTED; this.emit(BootloaderEvent.FAILED); } async onXModemData(data, progressPc) { this.emit(BootloaderEvent.UPLOAD_PROGRESS, progressPc); this.transport.write(data); } async onXModemStart() { this.emit(BootloaderEvent.UPLOAD_START); } async onXModemStop(status) { this.resolveState(BootloaderState.UPLOADED); this.emit(BootloaderEvent.UPLOAD_STOP, status); } readBootloaderInfo(buffer, blvIndex) { // cleanup start let startIndex = 0; if (buffer[0] === CARRIAGE_RETURN) { startIndex = buffer[1] === NEWLINE ? 2 : 1; } else if (buffer[0] === NEWLINE) { startIndex = 1; } const infoBuf = buffer.subarray(startIndex, buffer.indexOf(NEWLINE, blvIndex) + 1); if (infoBuf.length === 0) { return ["", false]; } logger.debug(`Reading info from: ${infoBuf.toString("hex")}.`, NS); let hasNewline = true; let newlineStart = 0; const lines = []; while (hasNewline) { const newlineEnd = infoBuf.indexOf(NEWLINE, newlineStart); if (newlineEnd === -1) { hasNewline = false; } else { const newline = infoBuf.subarray(newlineStart, newlineEnd - (infoBuf[newlineEnd - 1] === CARRIAGE_RETURN ? 1 : 0)); newlineStart = newlineEnd + 1; if (newline.length > 2) { lines.push(newline.toString("ascii")); } } } return [lines.join(". "), lines.length > 1]; // regular only has the bootloader version line, if more, means extra } resolveState(state) { if (this.waiter?.state === state) { clearTimeout(this.waiter.timeout); this.waiter.resolve(true); this.waiter = undefined; } logger.debug(`New bootloader state: ${BootloaderState[state]}.`, NS); // always set even if no waiter this.state = state; } waitForState(state, timeout = 5000, fail = true, expectingFail = false) { return new Promise((resolve) => { this.waiter = { resolve, state, timeout: setTimeout(() => { const msg = `Timed out waiting for ${BootloaderState[state]} after ${timeout}ms.`; if (fail) { logger.error(msg, NS); this.emit(BootloaderEvent.FAILED); return; } if (!expectingFail) { logger.debug(msg, NS); } resolve(false); this.waiter = undefined; }, timeout), }; }); } }