UNPKG

zigbee-herdsman

Version:

An open source Zigbee gateway solution with node.js.

467 lines 22.6 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.OtaSession = exports.UPGRADE_FILE_IDENTIFIER_BUF = exports.UPGRADE_FILE_IDENTIFIER = exports.OtaTagId = void 0; exports.setOtaConfiguration = setOtaConfiguration; exports.getOtaFirmware = getOtaFirmware; exports.getOtaIndex = getOtaIndex; exports.parseOtaHeader = parseOtaHeader; exports.parseOtaSubElement = parseOtaSubElement; exports.parseOtaImage = parseOtaImage; const node_assert_1 = __importDefault(require("node:assert")); const node_crypto_1 = __importDefault(require("node:crypto")); const node_fs_1 = require("node:fs"); const node_path_1 = __importDefault(require("node:path")); const node_perf_hooks_1 = require("node:perf_hooks"); const logger_1 = require("../../utils/logger"); const Zcl = __importStar(require("../../zspec/zcl")); const NS = "zh:controller:ota"; // OTA_HEADER_VERSION_ZIGBEE 0x0100 // ZIGBEE_2006_STACK_VERSION 0x0000 // ZIGBEE_2007_STACK_VERSION 0x0001 // ZIGBEE_PRO_STACK_VERSION 0x0002 // ZIGBEE_IP_STACK_VERSION 0x0003 // MANUFACTURE_CODE_WILD_CARD 0xFFFF // IMAGE_TYPE_WILD_CARD 0xFFFF // IMAGE_TYPE_SECURITY 0xFFC0 // IMAGE_TYPE_CONFIG 0xFFC1 // IMAGE_TYPE_LOG 0xFFC2 // FILE_VERSION_WILD_CARD 0xFFFFFFFF var OtaTagId; (function (OtaTagId) { OtaTagId[OtaTagId["UpgradeImage"] = 0] = "UpgradeImage"; /** signer IEEE address (8-byte), signature data (42-byte) */ OtaTagId[OtaTagId["ECDSASignatureCryptoSuite1"] = 1] = "ECDSASignatureCryptoSuite1"; /** ECDSA certificate (48-byte) */ OtaTagId[OtaTagId["ECDSASigningCertificateCryptoSuite1"] = 2] = "ECDSASigningCertificateCryptoSuite1"; /** hash value (16-byte) */ OtaTagId[OtaTagId["ImageIntegrityCode"] = 3] = "ImageIntegrityCode"; /** */ OtaTagId[OtaTagId["PictureData"] = 4] = "PictureData"; /** signer IEEE address (8-byte), signature data (72-byte) */ OtaTagId[OtaTagId["ECDSASignatureCryptoSuite2"] = 5] = "ECDSASignatureCryptoSuite2"; /** ECDSA certificate (74-byte) */ OtaTagId[OtaTagId["ECDSASigningCertificateCryptoSuite2"] = 6] = "ECDSASigningCertificateCryptoSuite2"; // "Manufacturer Specific Use" = 0xf000 – 0xffff, /** * 2-byte header before actual `UpgradeImage` * see https://github.com/telink-semi/telink_zigbee_sdk/blob/d5bc2f7b0c1f8536fe21c8127ca680ea8214bc8e/tl_zigbee_sdk/zigbee/ota/ota.h#L38 */ OtaTagId[OtaTagId["TelinkAES"] = 61440] = "TelinkAES"; // IkeaUnknown1 = 0xffbf, // parse fine as regular tag // IkeaUnknown2 = 0xffbe, // parse fine as regular tag (custom ECDSA?) })(OtaTagId || (exports.OtaTagId = OtaTagId = {})); const ZIGBEE_OTA_LATEST_URL = "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/index.json"; const ZIGBEE_OTA_PREVIOUS_URL = "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/index1.json"; const UPGRADE_END_REQUEST_ID = Zcl.Clusters.genOta.commands.upgradeEndRequest.ID; const IMAGE_BLOCK_REQUEST_ID = Zcl.Clusters.genOta.commands.imageBlockRequest.ID; const IMAGE_PAGE_REQUEST_ID = Zcl.Clusters.genOta.commands.imagePageRequest.ID; /** uint32 LE */ exports.UPGRADE_FILE_IDENTIFIER = 0x0beef11e; exports.UPGRADE_FILE_IDENTIFIER_BUF = Buffer.from([0x1e, 0xf1, 0xee, 0x0b]); // #region General Utils let dataDir; let overrideIndexLocation; /** * Set the dataDir for relative path needs (firmware file, index) as well as override index if any. */ function setOtaConfiguration(inDataDir, inOverrideIndexLocation) { dataDir = inDataDir; // If the file name is not a full path, then treat it as a relative to the data directory overrideIndexLocation = inOverrideIndexLocation && !isValidUrl(inOverrideIndexLocation) && !node_path_1.default.isAbsolute(inOverrideIndexLocation) ? node_path_1.default.join(dataDir, inOverrideIndexLocation) : inOverrideIndexLocation; } function isValidUrl(url) { try { const parsed = new URL(url); return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } } async function getJson(pageUrl) { const response = await fetch(pageUrl); if (!response.ok || !response.body) { throw new Error(`Invalid response from ${pageUrl} status=${response.status}`); } return (await response.json()); } /** * Validates a firmware file and returns the appropriate buffer (without possible manufacturer-specific header) */ function validateFirmware(fileBuffer, sha512) { const otaIdentifier = fileBuffer.indexOf(exports.UPGRADE_FILE_IDENTIFIER_BUF); (0, node_assert_1.default)(otaIdentifier !== -1, "Not a valid OTA file"); if (sha512) { const hash = node_crypto_1.default.createHash("sha512"); hash.update(fileBuffer); (0, node_assert_1.default)(hash.digest("hex") === sha512, "File checksum validation failed"); } return fileBuffer.subarray(otaIdentifier); } async function fetchFirmware(url, sha512) { logger_1.logger.debug(() => `Downloading firmware image from '${url}'`, NS); const firmwareFileRsp = await fetch(url); if (!firmwareFileRsp.ok || !firmwareFileRsp.body) { throw new Error(`Invalid response from ${url} status=${firmwareFileRsp.status}`); } const fileBuffer = Buffer.from(await firmwareFileRsp.arrayBuffer()); return validateFirmware(fileBuffer, sha512); } function readFirmware(filePath, sha512) { logger_1.logger.debug(() => `Reading firmware image from '${filePath}'`, NS); if (dataDir && !node_path_1.default.isAbsolute(filePath)) { // If the file name is not a full path, then treat it as a relative to the data directory filePath = node_path_1.default.join(dataDir, filePath); } const fileBuffer = (0, node_fs_1.readFileSync)(filePath); return validateFirmware(fileBuffer, sha512); } async function getOtaFirmware(url, sha512) { return isValidUrl(url) ? await fetchFirmware(url, sha512) : readFirmware(url, sha512); } async function getOtaIndexInternal(url) { return isValidUrl(url) ? await getJson(url) : JSON.parse((0, node_fs_1.readFileSync)(url, "utf-8")); } async function getOtaIndex(source) { let mainIndexUrl; if (source.url) { if (!isValidUrl(source.url) && !node_path_1.default.isAbsolute(source.url)) { if (!dataDir) { throw new Error("Invalid OTA configuration"); } mainIndexUrl = node_path_1.default.join(dataDir, source.url); } else { mainIndexUrl = source.url; } } else { mainIndexUrl = source.downgrade ? ZIGBEE_OTA_PREVIOUS_URL : ZIGBEE_OTA_LATEST_URL; } if (overrideIndexLocation) { logger_1.logger.debug(`Loading override index '${overrideIndexLocation}'`, NS); const localIndex = await getOtaIndexInternal(overrideIndexLocation); // Resulting index will have overridden items first const mappedLocalIndex = localIndex.map((meta) => { // Web-hosted images must come with all fields filled already // Nothing to do if needed fields were filled already if (isValidUrl(meta.url) || (meta.imageType !== undefined && meta.manufacturerCode !== undefined && meta.fileVersion !== undefined)) { return meta; } const image = parseOtaImage(readFirmware(meta.url, meta.sha512)); meta.imageType = image.header.imageType; meta.manufacturerCode = image.header.manufacturerCode; meta.fileVersion = image.header.fileVersion; return meta; }); try { const mainIndex = await getOtaIndexInternal(mainIndexUrl); logger_1.logger.debug("Retrieved main index", NS); return mappedLocalIndex.concat(mainIndex); } catch { logger_1.logger.info("Failed to download main index, only override index is loaded", NS); return mappedLocalIndex; } } return await getOtaIndexInternal(mainIndexUrl); } // #endregion // #region OTA Utils function parseOtaHeader(buffer) { const otaUpgradeFileIdentifier = buffer.readUInt32LE(0); (0, node_assert_1.default)(exports.UPGRADE_FILE_IDENTIFIER === otaUpgradeFileIdentifier, "Not a valid OTA file"); const otaHeaderVersion = buffer.readUInt16LE(4); const otaHeaderLength = buffer.readUInt16LE(6); const otaHeaderFieldControl = buffer.readUInt16LE(8); const manufacturerCode = buffer.readUInt16LE(10); const imageType = buffer.readUInt16LE(12); const fileVersion = buffer.readUInt32LE(14); const zigbeeStackVersion = buffer.readUInt16LE(18); const otaHeaderString = buffer.toString("utf8", 20, 52); const totalImageSize = buffer.readUInt32LE(52); const header = { otaUpgradeFileIdentifier, otaHeaderVersion, otaHeaderLength, otaHeaderFieldControl, manufacturerCode, imageType, fileVersion, zigbeeStackVersion, otaHeaderString, totalImageSize, }; let headerPosition = 56; if (header.otaHeaderFieldControl & 1) { header.securityCredentialVersion = buffer.readUInt8(headerPosition); headerPosition += 1; } if (header.otaHeaderFieldControl & 2) { header.upgradeFileDestination = buffer.subarray(headerPosition, headerPosition + 8); headerPosition += 8; } if (header.otaHeaderFieldControl & 4) { header.minimumHardwareVersion = buffer.readUInt16LE(headerPosition); headerPosition += 2; header.maximumHardwareVersion = buffer.readUInt16LE(headerPosition); headerPosition += 2; } return header; } function parseOtaSubElement(buffer, position) { const tagId = buffer.readUInt16LE(position); const length = buffer.readUInt32LE(position + 2); // this is fine for now, no known other uses of this tag if (tagId === OtaTagId.TelinkAES) { // OTA_FLAG_IMAGE_ELEM_INFO1 (1-byte) + OTA_FLAG_IMAGE_ELEM_INFO2 (1-byte) // buffer.subarray(position + 6, position + 8); const data = buffer.subarray(position + 8, position + 8 + length); return [{ tagId, length, data }, 8]; } const data = buffer.subarray(position + 6, position + 6 + length); return [{ tagId, length, data }, 6]; } function parseOtaImage(buffer) { const header = parseOtaHeader(buffer); const raw = buffer.subarray(0, header.totalImageSize); let position = header.otaHeaderLength; const elements = []; while (position < header.totalImageSize) { const [element, metaOffset] = parseOtaSubElement(buffer, position); position += element.data.length + metaOffset; elements.push(element); } (0, node_assert_1.default)(position === header.totalImageSize, "Size mismatch"); return { header, elements, raw }; } function buildImageBlockPayload(image, requestPayload, pageOffset, pageSize, baseDataSize) { let dataSize = baseDataSize; if (requestPayload.manufacturerCode === Zcl.ManufacturerCode.INSTA_GMBH || requestPayload.manufacturerCode === Zcl.ManufacturerCode.DRESDEN_ELEKTRONIK_INGENIEURTECHNIK_GMBH) { // Insta and some Dresden Elektronik devices, OTA only works for data sizes 40 and smaller (= manufacturerCode 4474 [Insta], 4405 [Dresden Elektronik]). dataSize = 40; } if (requestPayload.manufacturerCode === Zcl.ManufacturerCode.LEGRAND_GROUP) { // Legrand devices (newer firmware) require up to 64 bytes (= manufacturerCode 4129), let it drive the size dataSize = requestPayload.maximumDataSize; } if (Number.isFinite(requestPayload.maximumDataSize)) { dataSize = Math.min(dataSize, requestPayload.maximumDataSize); } let start = requestPayload.fileOffset + pageOffset; /* v8 ignore start */ if (requestPayload.manufacturerCode === Zcl.ManufacturerCode.LEGRAND_GROUP && requestPayload.fileOffset === 50 && requestPayload.maximumDataSize === 12) { // Hack for https://github.com/Koenkk/zigbee-OTA/issues/328 (Legrand OTA not working) logger_1.logger.info("Detected Legrand firmware issue, attempting to reset the OTA stack", NS); // The following vector seems to buffer overflow the device to reset the OTA stack! start = 78; dataSize = 64; } /* v8 ignore stop */ if (pageSize) { dataSize = Math.min(dataSize, pageSize - pageOffset); } let end = start + dataSize; if (end > image.raw.length) { end = image.raw.length; } logger_1.logger.debug(() => `Request offsets: fileOffset=${requestPayload.fileOffset} pageOffset=${pageOffset} maximumDataSize=${requestPayload.maximumDataSize}`, NS); logger_1.logger.debug(() => `Payload offsets: start=${start} end=${end} dataSize=${dataSize}`, NS); return { status: Zcl.Status.SUCCESS, manufacturerCode: requestPayload.manufacturerCode, imageType: requestPayload.imageType, fileVersion: requestPayload.fileVersion, fileOffset: start, dataSize: end - start, data: image.raw.subarray(start, end), }; } class OtaSession { ieeeAddr; endpoint; image; onProgress; dataSettings; waitForOtaCommand; #lastBlockResponseTime = 0; #lastProgressUpdate = 0; #startTime; get startTime() { return this.#startTime; } constructor(ieeeAddr, endpoint, image, onProgress, dataSettings, waitForOtaCommand) { this.ieeeAddr = ieeeAddr; this.endpoint = endpoint; this.image = image; this.onProgress = onProgress; this.dataSettings = dataSettings; this.waitForOtaCommand = waitForOtaCommand; this.#startTime = node_perf_hooks_1.performance.now(); // TODO: should `dataSettings.requestTimeout` be override if >0? // to allow easier testing when devices misbehave otherwise potentially overridden by below switch // if (!this.dataSettings.requestTimeout) { switch (image.header.manufacturerCode) { case Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH: { // Bosch transmits the firmware updates in the background in their native implementation. // According to the app, this can take up to 2 days. Therefore, we assume to get at least // one package request per hour from the device here. this.dataSettings.requestTimeout = 60 * 60 * 1000; break; } case Zcl.ManufacturerCode.LEGRAND_GROUP: { // Increase the timeout for Legrand devices, so that they will re-initiate and update themselves // Newer firmwares have awkward behaviours when it comes to the handling of the last bytes of OTA updates this.dataSettings.requestTimeout = 30 * 60 * 1000; break; } case Zcl.ManufacturerCode.SHENZHEN_COOLKIT_TECHNOLOGY_CO_LTD: { if (image.header.imageType === 8199) { // increase the upgradeEndReq wait time to solve the problem of OTA timeout failure of Sonoff Devices // (https://github.com/Koenkk/zigbee-herdsman-converters/issues/6657) this.dataSettings.requestTimeout = 3600000; } break; } } // } if (!this.dataSettings.requestTimeout) { this.dataSettings.requestTimeout = 150000; // ensures never zero } if (!this.dataSettings.baseSize) { this.dataSettings.baseSize = 50; // ensures never zero } // report initial progress with estimated time const expectedBlocks = Math.ceil(image.header.totalImageSize / dataSettings.baseSize); const blocksPerSec = dataSettings.responseDelay > 0 ? Math.round((1000 / dataSettings.responseDelay) * 100) / 100 : 20; // (1000 / 50) const estimatedRemainingSeconds = expectedBlocks / blocksPerSec; onProgress(0, estimatedRemainingSeconds); logger_1.logger.info(() => `OTA update of '${this.ieeeAddr}' estimated at ${estimatedRemainingSeconds} seconds (${expectedBlocks} chunks, ${blocksPerSec} per second)`, NS); } async run() { // can take a long time, use max (int32 - 1), ~24 days const upgradeEndRequest = this.waitForOtaCommand(this.endpoint.ID, UPGRADE_END_REQUEST_ID, undefined, 2147483647); try { for await (const request of this.commandStream(upgradeEndRequest)) { if (request.command.ID === UPGRADE_END_REQUEST_ID) { return request; } if (request.command.ID === IMAGE_PAGE_REQUEST_ID) { const pagePayload = request.payload; let pageOffset = 0; while (pageOffset < pagePayload.pageSize) { pageOffset = await this.sendImageBlockResponse(pagePayload, request.header.transactionSequenceNumber, pageOffset, pagePayload.pageSize); } } else { await this.sendImageBlockResponse(request.payload, request.header.transactionSequenceNumber, 0, 0); } /* v8 ignore start */ } // this code is logically unreachable // the generator always yields `UPGRADE_END_REQUEST_ID` before completing or it will eventually throw a timeout // but TypeScript can't detect this and requires a return statement const endPayload = await upgradeEndRequest.promise; return endPayload; /* v8 ignore stop */ } catch (error) { upgradeEndRequest.cancel(); const err = error; err.message = `Device ${this.ieeeAddr} did not start/finish firmware download after being notified. (${err.message})`; throw err; } } async *commandStream(upgradeEndRequest) { while (true) { const imageBlockRequest = this.waitForOtaCommand(this.endpoint.ID, IMAGE_BLOCK_REQUEST_ID, undefined, this.dataSettings.requestTimeout); const imagePageRequest = this.waitForOtaCommand(this.endpoint.ID, IMAGE_PAGE_REQUEST_ID, undefined, this.dataSettings.requestTimeout); const dataRequest = Promise.race([imageBlockRequest.promise, imagePageRequest.promise]); const request = await Promise.race([dataRequest, upgradeEndRequest.promise]); imageBlockRequest.cancel(); imagePageRequest.cancel(); // if this is `UPGRADE_END_REQUEST_ID`, `run()` will return and thus terminate the generator (no endless loop possible) yield request; } } async sendImageBlockResponse(requestPayload, requestTsn, pageOffset, pageSize) { // throttle if needed let callNow = node_perf_hooks_1.performance.now(); const timeSinceLast = callNow - this.#lastBlockResponseTime; const delayNeeded = this.dataSettings.responseDelay - timeSinceLast; if (delayNeeded > 0) { await new Promise((resolve) => setTimeout(resolve, delayNeeded)); // avoids a second call to `performance.now()` callNow += delayNeeded; } this.#lastBlockResponseTime = callNow; try { const blockPayload = buildImageBlockPayload(this.image, requestPayload, pageOffset, pageSize, this.dataSettings.baseSize); await this.endpoint.commandResponse("genOta", "imageBlockResponse", blockPayload, undefined, requestTsn); const nextOffset = pageOffset + blockPayload.dataSize; const now = node_perf_hooks_1.performance.now(); if (now - this.#lastProgressUpdate > 30000) { const totalDurationSeconds = (now - this.#startTime) / 1000; const bytesPerSecond = requestPayload.fileOffset / totalDurationSeconds; // first 30 seconds will be ignored due to first fileOffset being 0 // remain on ctor progress estimate until then (more reliable) if (bytesPerSecond > 0) { const percentage = Math.round((requestPayload.fileOffset / this.image.header.totalImageSize) * 10000) / 100; const remainingSeconds = Math.round((this.image.header.totalImageSize - requestPayload.fileOffset) / bytesPerSecond); logger_1.logger.info(() => `OTA update of '${this.ieeeAddr}' at ${percentage}%, ${remainingSeconds} seconds remaining`, NS); this.onProgress(percentage, remainingSeconds); } this.#lastProgressUpdate = now; } return nextOffset; } catch (error) { logger_1.logger.debug(() => `Image block response failed for ${this.ieeeAddr}: ${error.message}`, NS); return pageOffset; } } } exports.OtaSession = OtaSession; //# sourceMappingURL=ota.js.map