UNPKG

ember-zli

Version:

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

603 lines (602 loc) 27 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 = {})); const CARRIAGE_RETURN = 0x0d; const NEWLINE = 0x0a; const BOOTLOADER_KNOCK = Buffer.from([NEWLINE]); const BOOTLOADER_MENU_UPLOAD_GBL = Buffer.from([49 /* BootloaderMenu.UPLOAD_GBL */]); const BOOTLOADER_MENU_RUN = Buffer.from([50 /* BootloaderMenu.RUN */]); const BOOTLOADER_MENU_INFO = Buffer.from([51 /* 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)|(8\.[0-2])|(9\.[0-1])/; 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) { const isTcp = TCP_REGEX.test(this.portConf.path); // not already in bootloader, so launch it, then knock again const resetType = await select({ choices: [ { name: "EmberZNet NCP (a.k.a. zigbee_ncp, ncp-uart-hw, EZSP NCP)", value: 0 /* FirmwareType.EMBERZNET_NCP */ }, { name: "OpenThread RCP (a.k.a. openthread_rcp, ot-rcp)", value: 1 /* FirmwareType.OPENTHREAD_RCP */ }, { name: "Multiprotocol RCP (a.k.a. rcp-uart-802154)", value: 2 /* FirmwareType.MULTIPROTOCOL_RCP */ }, { name: "Reset via DTR/RTS flipping", value: 98, disabled: isTcp }, { name: "Reset via baudrate flipping", value: 99, disabled: isTcp }, ], message: "Currently installed firmware or specific reset method", }); switch (resetType) { case 0 /* FirmwareType.EMBERZNET_NCP */: { await this.ezspLaunch(); break; } case 1 /* FirmwareType.OPENTHREAD_RCP */: { await this.spinelLaunch(); break; } case 2 /* FirmwareType.MULTIPROTOCOL_RCP */: { await this.cpcLaunch(); break; } case 98: { await this.dtrRtsReset(false, true); break; } case 99: { await this.baudrateReset(); 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("failed" /* BootloaderEvent.FAILED */); return; } } logger.info("Connected to bootloader.", NS); } async navigate(menu, firmware) { this.waiter = undefined; this.state = BootloaderState.IDLE; switch (menu) { case 49 /* 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 50 /* BootloaderMenu.RUN */: { return await this.menuRun(); } case 51 /* BootloaderMenu.INFO */: { return await this.menuGetInfo(); } case 254 /* 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 255 /* 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 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}. For TCP adapters, it will require support on the other chip.`, NS); } if (this.portConf.rtscts === true && (recdMetadata.fw_variant === "sw_flow" || recdMetadata.fw_variant === "no_flow")) { logger.warning(`Firmware file variant ${recdMetadata.fw_variant} differs from your current port configuration of rtscts=true. For TCP adapters, it will require support on the other chip.`, NS); } if (!(recdMetadata.ezsp_version && SUPPORTED_VERSIONS_REGEX.test(recdMetadata.ezsp_version)) && !(recdMetadata.fw_version && SUPPORTED_VERSIONS_REGEX.test(recdMetadata.fw_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.fw_version ?? recdMetadata.ezsp_version ?? recdMetadata.ot_version ?? recdMetadata.cpc_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 dtrRtsReset(exit, fail = false) { if (!this.transport.isSerial) { logger.debug("DTR/RTS reset unavailable for TCP.", NS); return false; } if (exit) { logger.debug("Exiting bootloader using DTR/RTS flipping...", NS); } else { logger.debug("Launching bootloader using DTR/RTS flipping...", NS); } try { await this.transport.initPort(); await this.transport.serialSet({ dtr: false, rts: true }); await this.transport.serialSet({ dtr: true, rts: false }, 100); await this.transport.serialSet({ dtr: false, rts: false }, 500); return true; } catch (error) { logger.warning(`Unable to launch bootloader with DTR/RTS flipping: ${error}.`, NS); if (fail) { await this.transport.close(false, false); // force failed below this.emit("failed" /* BootloaderEvent.FAILED */); } return false; } } async baudrateReset(fail = false) { logger.debug("Launching bootloader using baudrate flipping...", NS); if (!this.transport.isSerial) { logger.debug("Baudrate reset unavailable for TCP.", NS); return; } try { await this.transport.initPort(undefined, 150); await new Promise((resolve) => setTimeout(resolve, 100)); await this.transport.initPort(undefined, 300); await new Promise((resolve) => setTimeout(resolve, 100)); await this.transport.initPort(undefined, 1200); await new Promise((resolve) => setTimeout(resolve, 100)); this.transport.write(Buffer.from("BZ", "ascii")); await new Promise((resolve) => setTimeout(resolve, 500)); } catch (error) { logger.warning(`Unable to launch bootloader with baudrate flipping: ${error}.`, NS); if (fail) { await this.transport.close(false, false); // force failed below this.emit("failed" /* BootloaderEvent.FAILED */); } } } 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("failed" /* 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("failed" /* 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("failed" /* BootloaderEvent.FAILED */); return; } // free serial await spinel.stop(); } async knock(fail) { if (this.state === BootloaderState.IDLE) { // nothing else to do if already got the bl prompt return; } logger.info(fail ? "Entering bootloader..." : "Trying to enter bootloader...", NS); try { await this.transport.initPort(); } catch (error) { logger.error(`Failed to open port: ${error}.`, NS); await this.transport.close(false, false); // force failed below this.emit("failed" /* 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, false); if (res) { break; } if (i === 1 && this.transport.isSerial) { // if failed first attempt, try second time with RTS/CTS enabled try { await this.transport.serialSet({ rts: true, cts: true }); } catch (error) { logger.debug(`Failed to set serial: ${error}.`, NS); } } } 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); const dtrRtsResetSuccess = await this.dtrRtsReset(true); if (!dtrRtsResetSuccess) { 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; } 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 this.xmodem.process(received); return; } 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("failed" /* BootloaderEvent.FAILED */); } onXModemData(data, progressPc) { this.emit("uploadProgress" /* BootloaderEvent.UPLOAD_PROGRESS */, progressPc); this.transport.write(data); } onXModemStart() { this.emit("uploadStart" /* BootloaderEvent.UPLOAD_START */); } onXModemStop(status) { this.resolveState(BootloaderState.UPLOADED); this.emit("uploadStop" /* 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("failed" /* BootloaderEvent.FAILED */); return; } if (!expectingFail) { logger.debug(msg, NS); } resolve(false); this.waiter = undefined; }, timeout), }; }); } }