"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;
const IMAGE_BLOCK_RESPONSE_ID = Zcl.Clusters.genOta.commandsResponse.imageBlockResponse.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(abortSignal) {
        // can take a long time, use max (int32 - 1), ~24 days
        // never match on defaultRsp
        const upgradeEndRequest = this.waitForOtaCommand(this.endpoint.ID, UPGRADE_END_REQUEST_ID, -1, 2147483647);
        try {
            for await (const request of this.commandStream(upgradeEndRequest)) {
                if (request.header.isGlobal) {
                    // ignore default responses, device should continue requesting blocks
                    continue;
                }
                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, abortSignal.aborted);
                    }
                }
                else {
                    await this.sendImageBlockResponse(request.payload, request.header.transactionSequenceNumber, 0, 0, abortSignal.aborted);
                }
                /* 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();
            if (error.message === "abort") {
                throw new Error(`OTA for device ${this.ieeeAddr} was aborted`);
            }
            throw new Error(`Device ${this.ieeeAddr} did not start/finish firmware download after being notified. (${error.message})`);
        }
    }
    async *commandStream(upgradeEndRequest) {
        while (true) {
            const imageBlockRequest = this.waitForOtaCommand(this.endpoint.ID, IMAGE_BLOCK_REQUEST_ID, IMAGE_BLOCK_RESPONSE_ID, this.dataSettings.requestTimeout);
            const imagePageRequest = this.waitForOtaCommand(this.endpoint.ID, IMAGE_PAGE_REQUEST_ID, IMAGE_BLOCK_RESPONSE_ID, 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, abort) {
        // 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;
        if (abort) {
            try {
                await this.endpoint.commandResponse("genOta", "imageBlockResponse", { status: Zcl.Status.ABORT }, undefined, requestTsn);
            }
            catch (error) {
                logger_1.logger.debug(() => `Abort image block response failed for ${this.ieeeAddr}: ${error.message}`, NS);
            }
            throw new Error("abort");
        }
        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