"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.UPGRADE_FILE_IDENTIFIER = exports.DEFAULT_IMAGE_BLOCK_RESPONSE_DELAY = exports.DEFAULT_MAXIMUM_DATA_SIZE = exports.ZIGBEE_OTA_PREVIOUS_URL = exports.ZIGBEE_OTA_LATEST_URL = void 0;
exports.setConfiguration = setConfiguration;
exports.isValidUrl = isValidUrl;
exports.parseImage = parseImage;
exports.isUpdateAvailable = isUpdateAvailable;
exports.update = update;
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 buffer_crc32_1 = __importDefault(require("buffer-crc32"));
const zigbee_herdsman_1 = require("zigbee-herdsman");
const logger_1 = require("./logger");
const NS = "zhc:ota";
exports.ZIGBEE_OTA_LATEST_URL = "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/index.json";
exports.ZIGBEE_OTA_PREVIOUS_URL = "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/index1.json";
/** +- 24 days */
const MAX_TIMEOUT = 2147483647;
/** When the data size is too big, OTA gets unstable, so default it to 50 bytes maximum. */
exports.DEFAULT_MAXIMUM_DATA_SIZE = 50;
/** Use to reduce network congestion by throttling response if necessary */
exports.DEFAULT_IMAGE_BLOCK_RESPONSE_DELAY = 250;
/** Consider update done after this amount of time without having seen a deviceAnnounce */
const UPDATE_END_FORCE_RESOLVE_TIME = 120 * 1000;
exports.UPGRADE_FILE_IDENTIFIER = Buffer.from([0x1e, 0xf1, 0xee, 0x0b]);
const VALID_SILABS_CRC = 0x2144df1c;
const EBL_TAG_HEADER = 0x0;
const EBL_TAG_ENC_HEADER = 0xfb05;
const EBL_TAG_END = 0xfc04;
const EBL_PADDING = 0xff;
const EBL_IMAGE_SIGNATURE = 0xe350;
const GBL_HEADER_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]);
// #region Configuration
let dataDir;
let overrideIndexFileName;
let imageBlockResponseDelay = exports.DEFAULT_IMAGE_BLOCK_RESPONSE_DELAY;
let initialMaximumDataSize = exports.DEFAULT_MAXIMUM_DATA_SIZE;
function setConfiguration(settings) {
    dataDir = settings.dataDir;
    overrideIndexFileName = settings.overrideIndexLocation;
    // use || no zero values
    imageBlockResponseDelay = settings.imageBlockResponseDelay || exports.DEFAULT_IMAGE_BLOCK_RESPONSE_DELAY;
    initialMaximumDataSize = settings.defaultMaximumDataSize || exports.DEFAULT_MAXIMUM_DATA_SIZE;
}
// #endregion
// #region General Utils
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());
}
function readLocalFile(fileName) {
    // If the file name is not a full path, then treat it as a relative to the data directory
    if (!node_path_1.default.isAbsolute(fileName) && dataDir) {
        // biome-ignore lint/style/noParameterAssign: ignored using `--suppress`
        fileName = node_path_1.default.join(dataDir, fileName);
    }
    logger_1.logger.debug(`Getting local firmware file '${fileName}'`, NS);
    return (0, node_fs_1.readFileSync)(fileName);
}
async function getFirmwareFile(meta) {
    const urlOrName = meta.url;
    // First try to download firmware file with the URL provided
    if (isValidUrl(urlOrName)) {
        logger_1.logger.debug(`Downloading firmware image from '${urlOrName}'`, NS);
        const firmwareFileRsp = await fetch(urlOrName);
        if (!firmwareFileRsp.ok || !firmwareFileRsp.body) {
            throw new Error(`Invalid response from ${urlOrName} status=${firmwareFileRsp.status}.`);
        }
        return Buffer.from(await firmwareFileRsp.arrayBuffer());
    }
    logger_1.logger.debug(`Try to read firmware image from local file '${urlOrName}'`, NS);
    return readLocalFile(urlOrName);
}
// #endregion
// #region OTA Utils
function parseSubElement(buffer, position) {
    const tagID = buffer.readUInt16LE(position);
    const length = buffer.readUInt32LE(position + 2);
    const data = buffer.subarray(position + 6, position + 6 + length);
    return { tagID, length, data };
}
function parseImage(buffer, suppressElementImageParseFailure = false) {
    const header = {
        otaUpgradeFileIdentifier: buffer.subarray(0, 4),
        otaHeaderVersion: buffer.readUInt16LE(4),
        otaHeaderLength: buffer.readUInt16LE(6),
        otaHeaderFieldControl: buffer.readUInt16LE(8),
        manufacturerCode: buffer.readUInt16LE(10),
        imageType: buffer.readUInt16LE(12),
        fileVersion: buffer.readUInt32LE(14),
        zigbeeStackVersion: buffer.readUInt16LE(18),
        otaHeaderString: buffer.toString("utf8", 20, 52),
        totalImageSize: buffer.readUInt32LE(52),
    };
    let headerPos = 56;
    let didSuppressElementImageParseFailure = false;
    /* istanbul ignore next */
    if (header.otaHeaderFieldControl & 1) {
        header.securityCredentialVersion = buffer.readUInt8(headerPos);
        headerPos += 1;
    }
    /* istanbul ignore next */
    if (header.otaHeaderFieldControl & 2) {
        header.upgradeFileDestination = buffer.subarray(headerPos, headerPos + 8);
        headerPos += 8;
    }
    if (header.otaHeaderFieldControl & 4) {
        header.minimumHardwareVersion = buffer.readUInt16LE(headerPos);
        headerPos += 2;
        header.maximumHardwareVersion = buffer.readUInt16LE(headerPos);
        headerPos += 2;
    }
    const raw = buffer.subarray(0, header.totalImageSize);
    // Note: in the context of this file, this can never assert, since both callers of `parseImage` already subarray to `UPGRADE_FILE_IDENTIFIER`
    (0, node_assert_1.default)(exports.UPGRADE_FILE_IDENTIFIER.equals(header.otaUpgradeFileIdentifier), "Not a valid OTA file");
    let position = header.otaHeaderLength;
    const elements = [];
    try {
        while (position < header.totalImageSize) {
            const element = parseSubElement(buffer, position);
            elements.push(element);
            position += element.data.length + 6;
        }
    }
    catch (error) {
        if (!suppressElementImageParseFailure) {
            throw error;
        }
        didSuppressElementImageParseFailure = true;
        logger_1.logger.error("Partially failed to parse the image, continuing anyway...", NS);
    }
    if (!didSuppressElementImageParseFailure) {
        (0, node_assert_1.default)(position === header.totalImageSize, "Size mismatch");
    }
    return { header, elements, raw };
}
function validateImageData(image) {
    for (const element of image.elements) {
        const { data } = element;
        if (data.indexOf(GBL_HEADER_TAG) === 0) {
            validateSilabsGbl(data);
        }
        else {
            const tag = data.readUInt16BE(0);
            /* istanbul ignore next */
            if ((tag === EBL_TAG_HEADER && data.readUInt16BE(6) === EBL_IMAGE_SIGNATURE) || tag === EBL_TAG_ENC_HEADER) {
                validateSilabsEbl(data);
            }
        }
    }
}
/* istanbul ignore next */
function validateSilabsEbl(data) {
    const dataLength = data.length;
    let position = 0;
    while (position + 4 <= dataLength) {
        const tag = data.readUInt16BE(position);
        const len = data.readUInt16BE(position + 2);
        position += 4 + len;
        if (tag !== EBL_TAG_END) {
            continue;
        }
        for (let position2 = position; position2 < dataLength; position2++) {
            (0, node_assert_1.default)(data.readUInt8(position2) === EBL_PADDING, "Image padding contains invalid bytes");
        }
        const calculatedCrc32 = buffer_crc32_1.default.unsigned(data.subarray(0, position));
        (0, node_assert_1.default)(calculatedCrc32 === VALID_SILABS_CRC, "Image CRC-32 is invalid");
        return;
    }
    throw new Error("Image is truncated, not long enough to contain a valid tag");
}
function validateSilabsGbl(data) {
    (0, node_assert_1.default)(data.indexOf(GBL_HEADER_TAG) === 0, "Not a valid GBL image");
    const gblEndTagIndex = data.lastIndexOf(GBL_END_TAG);
    (0, node_assert_1.default)(gblEndTagIndex > 16, "Not a valid GBL image"); // after HEADER, just because...
    const gblEnd = gblEndTagIndex + 12; // tag + length + crc32 (4*3)
    // TODO: nodejs >= v20.15.0, remove dep buffer-crc32
    //       import {crc32} from 'zlib';
    //       const calculatedCrc32 = crc32(data.subarray(0, gblEnd));
    // ignore possible padding
    const calculatedCrc32 = buffer_crc32_1.default.unsigned(data.subarray(0, gblEnd));
    (0, node_assert_1.default)(calculatedCrc32 === VALID_SILABS_CRC, "Image CRC-32 is invalid");
}
function fillImageInfo(meta) {
    // Web-hosted images must come with all fields filled already
    if (isValidUrl(meta.url)) {
        return meta;
    }
    // Nothing to do if needed fields were filled already
    if (meta.imageType !== undefined && meta.manufacturerCode !== undefined && meta.fileVersion !== undefined) {
        return meta;
    }
    // If no fields provided - get them from the image file
    const imageFile = readLocalFile(meta.url);
    const otaIdentifier = imageFile.indexOf(exports.UPGRADE_FILE_IDENTIFIER);
    (0, node_assert_1.default)(otaIdentifier !== -1, "Not a valid OTA file");
    // allow bypass non-spec Ledvance OTA files if proper manufacturer set
    const image = parseImage(imageFile.subarray(otaIdentifier), meta.manufacturerCode === zigbee_herdsman_1.Zcl.ManufacturerCode.LEDVANCE_GMBH);
    // Will fill only those fields that were absent
    if (meta.imageType === undefined) {
        meta.imageType = image.header.imageType;
    }
    if (meta.manufacturerCode === undefined) {
        meta.manufacturerCode = image.header.manufacturerCode;
    }
    if (meta.fileVersion === undefined) {
        meta.fileVersion = image.header.fileVersion;
    }
    return meta;
}
async function getIndex(previous) {
    const mainIndex = await getJson(previous ? exports.ZIGBEE_OTA_PREVIOUS_URL : exports.ZIGBEE_OTA_LATEST_URL);
    logger_1.logger.debug("Downloaded main index", NS);
    if (overrideIndexFileName) {
        logger_1.logger.debug(`Loading override index '${overrideIndexFileName}'`, NS);
        const localIndex = isValidUrl(overrideIndexFileName)
            ? await getJson(overrideIndexFileName)
            : JSON.parse((0, node_fs_1.readFileSync)(overrideIndexFileName, "utf-8"));
        // Resulting index will have overridden items first
        return localIndex.map((image) => fillImageInfo(image)).concat(mainIndex);
    }
    return mainIndex;
}
function deviceLogString(device) {
    return `[${device.ieeeAddr} | ${device.modelID}]`;
}
// #endregion
// #region OTA
function cancelWaiters(waiters) {
    waiters.imageBlockOrPageRequest?.cancel();
    waiters.upgradeEndRequest?.cancel();
}
function getOTAEndpoint(device) {
    return device.endpoints.find((e) => e.supportsOutputCluster("genOta"));
}
async function sendQueryNextImageResponse(device, endpoint, image, requestTransactionSequenceNumber) {
    const payload = {
        status: zigbee_herdsman_1.Zcl.Status.SUCCESS,
        manufacturerCode: image.header.manufacturerCode,
        imageType: image.header.imageType,
        fileVersion: image.header.fileVersion,
        imageSize: image.header.totalImageSize,
    };
    try {
        await endpoint.commandResponse("genOta", "queryNextImageResponse", payload, undefined, requestTransactionSequenceNumber);
    }
    catch (error) {
        logger_1.logger.debug(() => `${deviceLogString(device)} Failed to send queryNextImageResponse: ${error.message}`, NS);
    }
}
async function imageNotify(endpoint) {
    await endpoint.commandResponse("genOta", "imageNotify", { payloadType: 0, queryJitter: 100 }, { sendPolicy: "immediate" });
}
async function requestOTA(endpoint) {
    // Some devices (e.g. Insta) take very long trying to discover the correct coordinator EP for OTA.
    const queryNextImageRequest = endpoint.waitForCommand("genOta", "queryNextImageRequest", undefined, 60000);
    try {
        await imageNotify(endpoint);
        const response = await queryNextImageRequest.promise;
        return [response.header.transactionSequenceNumber, response.payload];
    }
    catch {
        queryNextImageRequest.cancel();
        throw new Error(`Device didn't respond to OTA request`);
    }
}
// this is not significant for tests, skipping coverage
/* istanbul ignore next */
function getInitialMaximumDataSize(imageBlockRequest) {
    if (imageBlockRequest.payload.manufacturerCode === zigbee_herdsman_1.Zcl.ManufacturerCode.INSTA_GMBH) {
        // Insta devices, OTA only works for data sizes 40 and smaller (= manufacturerCode 4474).
        return 40;
    }
    if (imageBlockRequest.payload.manufacturerCode === zigbee_herdsman_1.Zcl.ManufacturerCode.LEGRAND_GROUP) {
        // Legrand devices (newer firmware) require up to 64 bytes (= manufacturerCode 4129).
        return Number.POSITIVE_INFINITY;
    }
    return initialMaximumDataSize;
}
function getImageBlockResponsePayload(device, image, imageBlockRequest, pageOffset, pageSize) {
    let dataSize = Math.min(getInitialMaximumDataSize(imageBlockRequest), imageBlockRequest.payload.maximumDataSize);
    let start = imageBlockRequest.payload.fileOffset + pageOffset;
    // Hack for https://github.com/Koenkk/zigbee-OTA/issues/328 (Legrand OTA not working)
    /* istanbul ignore next */
    if (imageBlockRequest.payload.manufacturerCode === zigbee_herdsman_1.Zcl.ManufacturerCode.LEGRAND_GROUP &&
        imageBlockRequest.payload.fileOffset === 50 &&
        imageBlockRequest.payload.maximumDataSize === 12) {
        logger_1.logger.info(() => `${deviceLogString(device)} 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;
    }
    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(() => `${deviceLogString(device)} Request offsets: fileOffset=${imageBlockRequest.payload.fileOffset} pageOffset=${pageOffset} maximumDataSize=${imageBlockRequest.payload.maximumDataSize}`, NS);
    logger_1.logger.debug(() => `${deviceLogString(device)} Payload offsets: start=${start} end=${end} dataSize=${dataSize}`, NS);
    return {
        status: zigbee_herdsman_1.Zcl.Status.SUCCESS,
        manufacturerCode: imageBlockRequest.payload.manufacturerCode,
        imageType: imageBlockRequest.payload.imageType,
        fileVersion: imageBlockRequest.payload.fileVersion,
        fileOffset: start,
        dataSize: end - start,
        data: image.raw.subarray(start, end),
    };
}
function callOnProgress(device, startTime, lastUpdate, imageBlockRequest, image, onProgress) {
    const now = Date.now();
    // Call on progress every +- 30 seconds
    if (lastUpdate === undefined || now - lastUpdate > 30000) {
        const totalDuration = (now - startTime) / 1000; // in seconds
        const bytesPerSecond = imageBlockRequest.payload.fileOffset / totalDuration;
        const remaining = (image.header.totalImageSize - imageBlockRequest.payload.fileOffset) / bytesPerSecond;
        let percentage = imageBlockRequest.payload.fileOffset / image.header.totalImageSize;
        percentage = Math.round(percentage * 10000) / 100;
        logger_1.logger.debug(() => `${deviceLogString(device)} Update at ${percentage}%, remaining ${remaining} seconds`, NS);
        onProgress(percentage, remaining === Number.POSITIVE_INFINITY ? undefined : remaining);
        return now;
    }
    return lastUpdate;
}
async function getImageMeta(current, device, extraMetas, previous) {
    logger_1.logger.debug(() => `${deviceLogString(device)} Getting image metadata...`, NS);
    const images = await getIndex(previous);
    // NOTE: Officially an image can be determined with a combination of manufacturerCode and imageType.
    // However Gledopto pro products use the same imageType (0) for every device while the image is different.
    // For this case additional identification through the modelId is done.
    // In the case of Tuya and Moes, additional identification is carried out through the manufacturerName.
    return images.find((i) => i.imageType === current.imageType &&
        i.manufacturerCode === current.manufacturerCode &&
        (i.minFileVersion === undefined || current.fileVersion >= i.minFileVersion) &&
        (i.maxFileVersion === undefined || current.fileVersion <= i.maxFileVersion) &&
        // let extra metas override the match from device.modelID, same for manufacturerName
        (!i.modelId || i.modelId === device.modelID || i.modelId === extraMetas.modelId) &&
        (!i.manufacturerName ||
            // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
            i.manufacturerName.includes(device.manufacturerName) ||
            // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
            i.manufacturerName.includes(extraMetas.manufacturerName)) &&
        (!extraMetas.otaHeaderString || i.otaHeaderString === extraMetas.otaHeaderString) &&
        (i.hardwareVersionMin === undefined ||
            (device.hardwareVersion !== undefined && device.hardwareVersion >= i.hardwareVersionMin) ||
            (extraMetas.hardwareVersionMin !== undefined && extraMetas.hardwareVersionMin >= i.hardwareVersionMin)) &&
        (i.hardwareVersionMax === undefined ||
            (device.hardwareVersion !== undefined && device.hardwareVersion <= i.hardwareVersionMax) ||
            (extraMetas.hardwareVersionMax !== undefined && extraMetas.hardwareVersionMax <= i.hardwareVersionMax)));
}
async function isImageAvailable(current, device, extraMetas, previous) {
    const imageSet = previous ? "previous" : "latest";
    logger_1.logger.debug(() => `${deviceLogString(device)} Checking ${imageSet} image availability, current: ${JSON.stringify(current)}`, NS);
    // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
    if (["lumi.airrtc.agl001", "lumi.curtain.acn003", "lumi.curtain.agl001"].includes(device.modelID)) {
        // The current.fileVersion which comes from the device is wrong.
        // Use the `lumiFileVersion` which comes from the manuSpecificLumi.attributeReport instead.
        // https://github.com/Koenkk/zigbee2mqtt/issues/16345#issuecomment-1454835056
        // https://github.com/Koenkk/zigbee2mqtt/issues/16345 doesn't seem to be needed for all
        // https://github.com/Koenkk/zigbee2mqtt/issues/15745
        if (device.meta.lumiFileVersion) {
            // biome-ignore lint/style/noParameterAssign: ignored using `--suppress`
            current = { ...current, fileVersion: device.meta.lumiFileVersion };
        }
    }
    const meta = await getImageMeta(current, device, extraMetas, previous);
    // Soft-fail because no images in repo/URL for specified device
    if (!meta) {
        logger_1.logger.debug(() => `${deviceLogString(device)} No ${imageSet} image currently available, current: ${JSON.stringify(current)}'`, NS);
        return {
            available: 0,
            currentFileVersion: current.fileVersion,
            otaFileVersion: current.fileVersion,
        };
    }
    logger_1.logger.debug(() => `${deviceLogString(device)} Result for ${imageSet} image availability, meta: '${JSON.stringify(meta)}'`, NS);
    /* istanbul ignore next */
    if (meta.releaseNotes) {
        logger_1.logger.info(() => `${deviceLogString(device)} Firmware release notes: ${
        // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
        meta.releaseNotes.replace(/[\r\n]/g, "")}`, NS);
    }
    // Negative number means the firmware is 'newer' than current one
    // Positive number means the firmware is 'older' than current one
    return {
        available: meta.force ? -1 : Math.sign(current.fileVersion - meta.fileVersion),
        currentFileVersion: current.fileVersion,
        otaFileVersion: meta.fileVersion,
    };
}
async function getImage(current, device, extraMetas, previous) {
    const meta = await getImageMeta(current, device, extraMetas, previous);
    if (!meta) {
        throw new Error(`${deviceLogString(device)} No image currently available`);
    }
    const imageSet = previous ? "previous" : "latest";
    logger_1.logger.info(() => `${deviceLogString(device)} Getting ${imageSet} image, meta: ${JSON.stringify(meta)}`, NS);
    if (previous) {
        (0, node_assert_1.default)(meta.fileVersion < current.fileVersion || meta.force, "No previous image available");
    }
    else {
        (0, node_assert_1.default)(meta.fileVersion > current.fileVersion || meta.force, "No new image available");
    }
    const downloadedFile = await getFirmwareFile(meta);
    if (meta.sha512) {
        const hash = node_crypto_1.default.createHash("sha512");
        hash.update(downloadedFile);
        (0, node_assert_1.default)(hash.digest("hex") === meta.sha512, "File checksum validation failed");
        logger_1.logger.debug(() => `${deviceLogString(device)} Image checksum validation succeeded.`, NS);
    }
    const otaIdentifier = downloadedFile.indexOf(exports.UPGRADE_FILE_IDENTIFIER);
    (0, node_assert_1.default)(otaIdentifier !== -1, "Not a valid OTA file");
    const image = parseImage(downloadedFile.subarray(otaIdentifier), extraMetas.suppressElementImageParseFailure || false);
    logger_1.logger.debug(() => `${deviceLogString(device)} Got ${imageSet} image, header: ${JSON.stringify(image.header)}`, NS);
    (0, node_assert_1.default)(image.header.fileVersion === meta.fileVersion, "File version mismatch");
    (0, node_assert_1.default)(!meta.fileSize || image.header.totalImageSize === meta.fileSize, "Image size mismatch");
    (0, node_assert_1.default)(image.header.manufacturerCode === current.manufacturerCode, "Manufacturer code mismatch");
    (0, node_assert_1.default)(image.header.imageType === current.imageType, "Image type mismatch");
    // this is only reachable if manifest is missing hardwareVersionMin/Max
    if ("minimumHardwareVersion" in image.header &&
        image.header.minimumHardwareVersion !== undefined &&
        "maximumHardwareVersion" in image.header &&
        image.header.maximumHardwareVersion !== undefined) {
        (0, node_assert_1.default)(device.hardwareVersion !== undefined, "Hardware version required");
        (0, node_assert_1.default)(image.header.minimumHardwareVersion <= device.hardwareVersion && device.hardwareVersion <= image.header.maximumHardwareVersion, "Hardware version mismatch");
    }
    validateImageData(image);
    return image;
}
async function isUpdateAvailable(device, extraMetas, requestPayload, previous) {
    logger_1.logger.debug(() => `${deviceLogString(device)} Checking if an update is available`, NS);
    if (device.modelID === "PP-WHT-US") {
        // see https://github.com/Koenkk/zigbee-OTA/pull/14
        const scenesEndpoint = device.endpoints.find((e) => e.supportsOutputCluster("genScenes"));
        if (scenesEndpoint) {
            await scenesEndpoint.write("genScenes", { currentGroup: 49502 });
        }
    }
    if (requestPayload === undefined) {
        const endpoint = getOTAEndpoint(device);
        (0, node_assert_1.default)(endpoint !== undefined, `${deviceLogString(device)} Failed to find an endpoint which supports the OTA cluster`);
        logger_1.logger.debug(() => `${deviceLogString(device)} Using endpoint '${endpoint.ID}'`, NS);
        const [, payload] = await requestOTA(endpoint);
        logger_1.logger.debug(() => `${deviceLogString(device)} Got request '${JSON.stringify(payload)}'`, NS);
        // biome-ignore lint/style/noParameterAssign: ignored using `--suppress`
        requestPayload = payload;
    }
    const availableResult = await isImageAvailable(requestPayload, device, extraMetas, previous);
    let available = false;
    if (previous) {
        available = availableResult.available > 0;
        logger_1.logger.debug(() => `${deviceLogString(device)} Downgrade available: ${available ? "YES" : "NO"}`, NS);
    }
    else {
        available = availableResult.available < 0;
        logger_1.logger.debug(() => `${deviceLogString(device)} Upgrade available: ${available ? "YES" : "NO"}`, NS);
        if (availableResult.available > 0) {
            logger_1.logger.warning(() => `${deviceLogString(device)} Firmware is newer than latest available firmware.`, NS);
        }
    }
    return { ...availableResult, available };
}
// this is not significant for tests, skipping coverage
/* istanbul ignore next */
function getImageBlockOrPageRequestTimeoutMs(requestPayload) {
    // 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)
    if (requestPayload.manufacturerCode === zigbee_herdsman_1.Zcl.ManufacturerCode.SHENZHEN_COOLKIT_TECHNOLOGY_CO_LTD && requestPayload.imageType === 8199) {
        return 3600000;
    }
    // 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.
    if (requestPayload.manufacturerCode === zigbee_herdsman_1.Zcl.ManufacturerCode.ROBERT_BOSCH_GMBH) {
        return 60 * 60 * 1000;
    }
    // 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
    if (requestPayload.manufacturerCode === zigbee_herdsman_1.Zcl.ManufacturerCode.LEGRAND_GROUP) {
        return 30 * 60 * 1000;
    }
    return 150000;
}
/**
 * @see https://zigbeealliance.org/wp-content/uploads/2021/10/07-5123-08-Zigbee-Cluster-Library.pdf 11.12
 */
async function update(device, extraMetas, previous, onProgress) {
    const imageSet = previous ? "previous" : "latest";
    logger_1.logger.debug(() => `${deviceLogString(device)} Updating to ${imageSet}`, NS);
    const endpoint = getOTAEndpoint(device);
    (0, node_assert_1.default)(endpoint !== undefined, `${deviceLogString(device)} Failed to find an endpoint which supports the OTA cluster`);
    logger_1.logger.debug(() => `${deviceLogString(device)} Using endpoint '${endpoint.ID}'`, NS);
    if (device.modelID === "PP-WHT-US") {
        // see https://github.com/Koenkk/zigbee-OTA/pull/14
        const scenesEndpoint = device.endpoints.find((e) => e.supportsOutputCluster("genScenes"));
        if (scenesEndpoint) {
            await scenesEndpoint.write("genScenes", { currentGroup: 49502 });
        }
    }
    const [transNum, requestPayload] = await requestOTA(endpoint);
    logger_1.logger.debug(() => `${deviceLogString(device)} Got request payload '${JSON.stringify(requestPayload)}'`, NS);
    const image = await getImage(requestPayload, device, extraMetas, previous);
    logger_1.logger.debug(() => `${deviceLogString(device)} Got ${imageSet} image`, NS);
    // reply to `queryNextImageRequest` in `requestOTA` now that we have the data for it,
    // should trigger image block/page request from device
    await sendQueryNextImageResponse(device, endpoint, image, transNum);
    const waiters = {};
    let lastBlockResponseTime = 0;
    let lastBlockTimeout;
    let lastUpdate = undefined;
    const startTime = Date.now();
    const sendImageBlockResponse = async (imageBlockRequest, pageOffset, pageSize) => {
        // Reduce network congestion by throttling response if necessary
        {
            clearTimeout(lastBlockTimeout);
            const now = Date.now();
            const timeSinceLast = now - lastBlockResponseTime;
            const delay = imageBlockResponseDelay - timeSinceLast;
            if (delay <= 0) {
                lastBlockResponseTime = now;
            }
            else {
                await new Promise((resolve) => {
                    lastBlockTimeout = setTimeout(() => {
                        lastBlockResponseTime = Date.now();
                        resolve();
                    }, delay);
                });
            }
        }
        try {
            const blockPayload = getImageBlockResponsePayload(device, image, imageBlockRequest, pageOffset, pageSize);
            await endpoint.commandResponse("genOta", "imageBlockResponse", blockPayload, undefined, imageBlockRequest.header.transactionSequenceNumber);
            // biome-ignore lint/style/noParameterAssign: ignored using `--suppress`
            pageOffset += blockPayload.dataSize;
        }
        catch (error) {
            // Shit happens, device will probably do a new imageBlockRequest so don't care.
            logger_1.logger.debug(() => `${deviceLogString(device)} Image block response failed: ${error.message}`, NS);
        }
        lastUpdate = callOnProgress(device, startTime, lastUpdate, imageBlockRequest, image, onProgress);
        return pageOffset;
    };
    let done = false;
    const imageBlockOrPageRequestTimeoutMs = getImageBlockOrPageRequestTimeoutMs(requestPayload);
    /** recursive, endless (expects `upgradeEndRequest` to stop it, or anything that sets done=true) */
    const sendImageChunks = async () => {
        while (!done) {
            const imageBlockRequest = endpoint.waitForCommand("genOta", "imageBlockRequest", undefined, imageBlockOrPageRequestTimeoutMs);
            const imagePageRequest = endpoint.waitForCommand("genOta", "imagePageRequest", undefined, imageBlockOrPageRequestTimeoutMs);
            waiters.imageBlockOrPageRequest = {
                promise: Promise.race([imageBlockRequest.promise, imagePageRequest.promise]),
                cancel: () => {
                    imageBlockRequest.cancel();
                    imagePageRequest.cancel();
                },
            };
            try {
                const result = await waiters.imageBlockOrPageRequest.promise;
                let pageSize = 0;
                let pageOffset = 0;
                if ("pageSize" in result.payload) {
                    // TODO: `result.payload.responseSpacing` support?
                    // imagePageRequest
                    pageSize = result.payload.pageSize;
                    while (pageOffset < pageSize) {
                        // in case upgradeEndRequest resolves, bail early (quirks)
                        if (done) {
                            return;
                        }
                        pageOffset = await sendImageBlockResponse(result, pageOffset, pageSize);
                    }
                }
                else {
                    // imageBlockRequest
                    pageOffset = await sendImageBlockResponse(result, pageOffset, pageSize);
                }
            }
            catch (error) {
                cancelWaiters(waiters);
                throw new Error(`${deviceLogString(device)} Timeout. Device did not start/finish firmware download after being notified. (${error.message})`);
            }
        }
    };
    logger_1.logger.debug(() => `${deviceLogString(device)} Starting update`, NS);
    waiters.upgradeEndRequest = endpoint.waitForCommand("genOta", "upgradeEndRequest", undefined, MAX_TIMEOUT);
    await Promise.race([
        sendImageChunks(),
        waiters.upgradeEndRequest.promise.finally(() => {
            clearTimeout(lastBlockResponseTime);
            // always clear state
            cancelWaiters(waiters);
            done = true;
        }),
    ]);
    // already resolved when this is reached
    const endResult = await waiters.upgradeEndRequest.promise;
    logger_1.logger.debug(() => `${deviceLogString(device)} Got upgrade end request: ${JSON.stringify(endResult.payload)}`, NS);
    if (endResult.payload.status === zigbee_herdsman_1.Zcl.Status.SUCCESS) {
        const payload = {
            manufacturerCode: image.header.manufacturerCode,
            imageType: image.header.imageType,
            fileVersion: image.header.fileVersion,
            currentTime: 0,
            upgradeTime: 1,
        };
        try {
            await endpoint.commandResponse("genOta", "upgradeEndResponse", payload, undefined, endResult.header.transactionSequenceNumber);
            logger_1.logger.debug(() => `${deviceLogString(device)} Update successful. Waiting for device announce...`, NS);
            onProgress(100, undefined);
            let timer;
            return await new Promise((resolve) => {
                // XXX: annoying to test since using fake timers, same result anyway
                /* istanbul ignore next */
                const onDeviceAnnounce = () => {
                    clearTimeout(timer);
                    logger_1.logger.debug(() => `${deviceLogString(device)} Received device announce, update finished.`, NS);
                    resolve(image.header.fileVersion);
                };
                // force "finished" after given time
                timer = setTimeout(() => {
                    device.removeListener("deviceAnnounce", onDeviceAnnounce);
                    logger_1.logger.debug(() => `${deviceLogString(device)} Timed out waiting for device announce, update considered finished.`, NS);
                    resolve(image.header.fileVersion);
                }, UPDATE_END_FORCE_RESOLVE_TIME);
                device.once("deviceAnnounce", onDeviceAnnounce);
            });
        }
        catch (error) {
            throw new Error(`Upgrade end response failed: ${error.message}`);
        }
    }
    else {
        /**
         * For other status value received such as INVALID_IMAGE, REQUIRE_MORE_IMAGE, or ABORT,
         * the upgrade server SHALL not send Upgrade End Response command but it SHALL send default
         * response command with status of success and it SHALL wait for the client to reinitiate the upgrade process.
         */
        try {
            await endpoint.defaultResponse(zigbee_herdsman_1.Zcl.Clusters.genOta.commands.upgradeEndRequest.ID, zigbee_herdsman_1.Zcl.Status.SUCCESS, zigbee_herdsman_1.Zcl.Clusters.genOta.ID, endResult.header.transactionSequenceNumber);
        }
        catch (error) {
            logger_1.logger.debug(() => `${deviceLogString(device)} Upgrade end request default response failed: ${error.message}`, NS);
        }
        throw new Error(`Update failed with reason: ${zigbee_herdsman_1.Zcl.Status[endResult.payload.status]}`);
    }
}
// #endregion
//# sourceMappingURL=ota.js.map