"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 __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
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 });
const node_assert_1 = __importDefault(require("node:assert"));
const node_fs_1 = require("node:fs");
const node_path_1 = require("node:path");
const bind_decorator_1 = __importDefault(require("bind-decorator"));
const json_stable_stringify_without_jsonify_1 = __importDefault(require("json-stable-stringify-without-jsonify"));
const zigbee_herdsman_1 = require("zigbee-herdsman");
const device_1 = __importDefault(require("../model/device"));
const data_1 = __importDefault(require("../util/data"));
const logger_1 = __importDefault(require("../util/logger"));
const settings = __importStar(require("../util/settings"));
const utils_1 = __importDefault(require("../util/utils"));
const extension_1 = __importDefault(require("./extension"));
/**
 * Write to `dataDir` and return created path
 */
function writeFirmwareHexToDataDir(hex, fileName, deviceIeee) {
    if (!fileName) {
        fileName = `${deviceIeee}_${Date.now()}`;
    }
    const baseDir = data_1.default.joinPath("ota");
    if (!(0, node_fs_1.existsSync)(baseDir)) {
        (0, node_fs_1.mkdirSync)(baseDir, { recursive: true });
    }
    const filePath = (0, node_path_1.join)(baseDir, fileName);
    (0, node_fs_1.writeFileSync)(filePath, Buffer.from(hex, "hex"));
    return filePath;
}
class OTAUpdate extends extension_1.default {
    #topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/ota_update/(update|check|schedule|unschedule)/?(downgrade|abort)?`, "i");
    #inProgress = new Set();
    #lastChecked = new Map();
    // biome-ignore lint/suspicious/useAwait: API
    async start() {
        this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
        this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
        (0, zigbee_herdsman_1.setOtaConfiguration)(data_1.default.getPath(), settings.get().ota.zigbee_ota_override_index_location);
        // In case Zigbee2MQTT is restared during an update, progress and remaining values are still in state, remove them.
        for (const device of this.zigbee.devicesIterator(utils_1.default.deviceNotCoordinator)) {
            this.#removeProgressAndRemainingFromState(device);
            // Reset update state, e.g. when Z2M restarted during update.
            if (this.state.get(device).update?.state === "updating") {
                this.state.get(device).update.state = "idle";
            }
        }
    }
    // mostly intended for testing
    clearState() {
        this.#inProgress.clear();
        this.#lastChecked.clear();
    }
    #removeProgressAndRemainingFromState(device) {
        const deviceState = this.state.get(device);
        if (deviceState.update) {
            delete deviceState.update.progress;
            delete deviceState.update.remaining;
        }
    }
    async onZigbeeEvent(data) {
        if (data.type !== "commandQueryNextImageRequest" || !data.device.definition || this.#inProgress.has(data.device.ieeeAddr)) {
            return;
        }
        // `commandQueryNextImageRequest` check above should ensures this is valid but...
        (0, node_assert_1.default)(data.meta.zclTransactionSequenceNumber !== undefined, "Missing 'queryNextImageRequest' transaction sequence number (cannot match reply)");
        logger_1.default.debug(`Device '${data.device.name}' requested OTA`);
        if (data.device.zh.scheduledOta) {
            // allow custom source to override check for definition `ota`
            if (data.device.zh.scheduledOta?.url !== undefined || data.device.definition.ota) {
                this.#inProgress.add(data.device.ieeeAddr);
                logger_1.default.info(`Updating '${data.device.name}' to latest firmware`);
                try {
                    const otaSettings = settings.get().ota;
                    const [, toVersion] = await this.#update(undefined, // uses internally registered schedule
                    data.device, data.data, data.meta.zclTransactionSequenceNumber, {
                        // fallbacks are only to satisfy typing, should always be defined from settings defaults
                        requestTimeout: otaSettings.image_block_request_timeout ?? /* v8 ignore next */ 150000,
                        responseDelay: otaSettings.image_block_response_delay ?? /* v8 ignore next */ 250,
                        baseSize: otaSettings.default_maximum_data_size ?? /* v8 ignore next */ 50,
                    }, data.endpoint);
                    if (toVersion === undefined) {
                        logger_1.default.info(`No OTA image currently available for '${data.device.name}'. Unscheduled.`);
                    }
                }
                catch (e) {
                    logger_1.default.debug(`OTA update of '${data.device.name}' failed (${e}). Retry scheduled for next request.`);
                    this.#removeProgressAndRemainingFromState(data.device);
                    await this.publishEntityState(data.device, this.#getEntityPublishPayload(data.device, "scheduled"));
                }
                this.#inProgress.delete(data.device.ieeeAddr);
                return; // we're done
            }
        }
        if (data.device.definition.ota) {
            if (!data.device.options.disable_automatic_update_check && !settings.get().ota.disable_automatic_update_check) {
                // When a device does a next image request, it will usually do it a few times after each other
                // with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
                // each time, so this interval can be set by the user. The default is 1,440 minutes (one day).
                const updateCheckInterval = settings.get().ota.update_check_interval * 1000 * 60;
                const deviceLastChecked = this.#lastChecked.get(data.device.ieeeAddr);
                const check = deviceLastChecked !== undefined ? Date.now() - deviceLastChecked > updateCheckInterval : true;
                if (!check) {
                    return;
                }
                this.#inProgress.add(data.device.ieeeAddr);
                this.#lastChecked.set(data.device.ieeeAddr, Date.now());
                let availableResult;
                try {
                    // auto-check defaults to zigbee-OTA + potential local index, and never `downgrade`
                    availableResult = await data.device.zh.checkOta({ downgrade: false }, data.data, data.device.otaExtraMetas, data.endpoint);
                }
                catch (error) {
                    logger_1.default.debug(`Failed to check if OTA update available for '${data.device.name}' (${error})`);
                }
                await this.publishEntityState(data.device, this.#getEntityPublishPayload(data.device, availableResult ?? "idle"));
                if (availableResult?.available) {
                    logger_1.default.info(`OTA update available for '${data.device.name}'`);
                }
            }
        }
        // Respond to stop the client from requesting OTAs
        await data.endpoint.commandResponse("genOta", "queryNextImageResponse", { status: zigbee_herdsman_1.Zcl.Status.NO_IMAGE_AVAILABLE }, undefined, data.meta.zclTransactionSequenceNumber);
        logger_1.default.debug(`Responded to OTA request of '${data.device.name}' with 'NO_IMAGE_AVAILABLE'`);
        this.#inProgress.delete(data.device.ieeeAddr);
    }
    async #readSoftwareBuildIDAndDateCode(device, sendPolicy) {
        try {
            const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster("genBasic"));
            (0, node_assert_1.default)(endpoint);
            const result = await endpoint.read("genBasic", ["dateCode", "swBuildId"], { sendPolicy });
            return { softwareBuildID: result.swBuildId, dateCode: result.dateCode };
        }
        catch {
            return undefined;
        }
    }
    #getEntityPublishPayload(device, state, progress, remaining) {
        const deviceUpdateState = this.state.get(device).update;
        const update = typeof state === "string"
            ? {
                state,
                installed_version: deviceUpdateState?.installed_version,
                latest_version: deviceUpdateState?.latest_version,
                latest_source: deviceUpdateState?.latest_source,
                latest_release_notes: deviceUpdateState?.latest_release_notes,
            }
            : {
                state: state.available ? "available" : "idle",
                installed_version: state.current.fileVersion,
                latest_version: state.availableMeta?.fileVersion ?? state.current.fileVersion,
                latest_source: state.availableMeta?.url || null,
                latest_release_notes: state.availableMeta?.releaseNotes || null,
            };
        if (progress !== undefined) {
            update.progress = progress;
        }
        if (remaining !== undefined) {
            update.remaining = Math.round(remaining);
        }
        return { update };
    }
    async onMQTTMessage(data) {
        const topicMatch = data.topic.match(this.#topicRegex);
        if (!topicMatch) {
            return;
        }
        const message = utils_1.default.parseJSON(data.message, data.message);
        // TODO: deprecated 3.0 should remove string payload, enforce object
        const messageObject = typeof message === "object";
        if (messageObject) {
            (0, node_assert_1.default)(message.id, "Invalid payload");
        }
        const id = (messageObject ? message.id : message);
        const device = this.zigbee.resolveEntity(id);
        const type = topicMatch[1];
        const downgrade = topicMatch[2] === "downgrade";
        const abort = topicMatch[2] === "abort";
        let error;
        let errorStack;
        if (!(device instanceof device_1.default)) {
            error = `Device '${id}' does not exist`;
        }
        else if (this.#inProgress.has(device.ieeeAddr)) {
            if (abort) {
                device.zh.abortOta();
                this.#inProgress.delete(device.ieeeAddr);
                // cleanup same as a fail
                this.#removeProgressAndRemainingFromState(device);
                await this.publishEntityState(device, this.#getEntityPublishPayload(device, "available"));
                await this.mqtt.publish("bridge/response/device/ota_update/update/abort", (0, json_stable_stringify_without_jsonify_1.default)(utils_1.default.getResponse(message, { id })));
            }
            else {
                // also guards against scheduling while check/update op in progress that could result in undesired OTA state
                error = `OTA update or check for update already in progress for '${device.name}'`;
            }
        }
        else {
            switch (type) {
                case "check": {
                    this.#inProgress.add(device.ieeeAddr);
                    const source = { downgrade };
                    if (messageObject) {
                        const payload = message;
                        if (payload.url) {
                            source.url = payload.url;
                        }
                        else if (!device.definition?.ota) {
                            error = `Device '${device.name}' does not support OTA updates`;
                            break;
                        }
                    }
                    else if (!device.definition?.ota) {
                        error = `Device '${device.name}' does not support OTA updates`;
                        break;
                    }
                    logger_1.default.info(`Checking if OTA update available for '${device.name}'`);
                    try {
                        const availableResult = await device.zh.checkOta(source, undefined, device.otaExtraMetas);
                        logger_1.default.info(`${availableResult.available ? "" : "No "}OTA update available for '${device.name}'`);
                        await this.publishEntityState(device, this.#getEntityPublishPayload(device, availableResult));
                        this.#lastChecked.set(device.ieeeAddr, Date.now());
                        const response = utils_1.default.getResponse(message, {
                            id,
                            update_available: availableResult.available,
                            downgrade: source.downgrade,
                            source: availableResult.availableMeta?.url,
                            release_notes: availableResult.availableMeta?.releaseNotes,
                        });
                        await this.mqtt.publish("bridge/response/device/ota_update/check", (0, json_stable_stringify_without_jsonify_1.default)(response));
                    }
                    catch (e) {
                        error = `Failed to check if OTA update available for '${device.name}' (${e.message})`;
                        errorStack = e.stack;
                    }
                    break;
                }
                case "update": {
                    if (abort) {
                        error = `No OTA in progress to abort for device '${device.name}'`;
                        break;
                    }
                    this.#inProgress.add(device.ieeeAddr);
                    const otaSettings = settings.get().ota;
                    const source = { downgrade };
                    const dataSettings = {
                        // fallbacks are only to satisfy typing, should always be defined from settings defaults
                        requestTimeout: otaSettings.image_block_request_timeout ?? /* v8 ignore next */ 150000,
                        responseDelay: otaSettings.image_block_response_delay ?? /* v8 ignore next */ 250,
                        baseSize: otaSettings.default_maximum_data_size ?? /* v8 ignore next */ 50,
                    };
                    if (messageObject) {
                        const payload = message;
                        if (payload.hex) {
                            (0, node_assert_1.default)(payload.hex.data);
                            // write to `dataDir` and pass created path as source URL
                            source.url = writeFirmwareHexToDataDir(payload.hex.data, payload.hex.file_name, device.ieeeAddr);
                        }
                        else if (payload.url) {
                            source.url = payload.url;
                        }
                        else if (!device.definition?.ota) {
                            error = `Device '${device.name}' does not support OTA updates`;
                            break;
                        }
                        if (payload.image_block_request_timeout) {
                            dataSettings.requestTimeout = payload.image_block_request_timeout;
                        }
                        if (payload.image_block_response_delay) {
                            dataSettings.responseDelay = payload.image_block_response_delay;
                        }
                        if (payload.default_maximum_data_size) {
                            dataSettings.baseSize = payload.default_maximum_data_size;
                        }
                    }
                    else if (!device.definition?.ota) {
                        error = `Device '${device.name}' does not support OTA updates`;
                        break;
                    }
                    logger_1.default.info(`OTA updating '${device.name}' to ${downgrade ? "previous" : "latest"} firmware`);
                    try {
                        const firmwareFrom = await this.#readSoftwareBuildIDAndDateCode(device, "immediate");
                        const [fromVersion, toVersion] = await this.#update(source, device, undefined, undefined, dataSettings);
                        if (toVersion === undefined) {
                            error = `Update of '${device.name}' failed (No image currently available)`;
                            break;
                        }
                        const firmwareTo = await this.#readSoftwareBuildIDAndDateCode(device);
                        const response = utils_1.default.getResponse(message, {
                            id,
                            from: {
                                file_version: fromVersion,
                                software_build_id: firmwareFrom?.softwareBuildID,
                                date_code: firmwareFrom?.dateCode,
                            },
                            to: { file_version: toVersion, software_build_id: firmwareTo?.softwareBuildID, date_code: firmwareTo?.dateCode },
                        });
                        await this.mqtt.publish("bridge/response/device/ota_update/update", (0, json_stable_stringify_without_jsonify_1.default)(response));
                    }
                    catch (e) {
                        logger_1.default.debug(`OTA update of '${device.name}' failed (${e})`);
                        error = `OTA update of '${device.name}' failed (${e.message})`;
                        errorStack = e.stack;
                        this.#removeProgressAndRemainingFromState(device);
                        await this.publishEntityState(device, this.#getEntityPublishPayload(device, "available"));
                    }
                    break;
                }
                case "schedule": {
                    const source = { downgrade };
                    if (messageObject) {
                        const payload = message;
                        if (payload.hex) {
                            (0, node_assert_1.default)(payload.hex.data);
                            // write to `dataDir` and pass created path as source URL
                            source.url = writeFirmwareHexToDataDir(payload.hex.data, payload.hex.file_name, device.ieeeAddr);
                        }
                        else if (payload.url) {
                            source.url = payload.url;
                        }
                        else if (!device.definition?.ota) {
                            error = `Device '${device.name}' does not support OTA updates`;
                            break;
                        }
                    }
                    else if (!device.definition?.ota) {
                        error = `Device '${device.name}' does not support OTA updates`;
                        break;
                    }
                    device.zh.scheduleOta(source);
                    await this.publishEntityState(device, this.#getEntityPublishPayload(device, "scheduled"));
                    const response = utils_1.default.getResponse(message, { id, url: source.url });
                    await this.mqtt.publish("bridge/response/device/ota_update/schedule", (0, json_stable_stringify_without_jsonify_1.default)(response));
                    break;
                }
                case "unschedule": {
                    if (device.zh.scheduledOta?.url?.startsWith(data_1.default.joinPath("ota"))) {
                        (0, node_fs_1.rmSync)(device.zh.scheduledOta.url, { force: true });
                    }
                    device.zh.unscheduleOta();
                    await this.publishEntityState(device, this.#getEntityPublishPayload(device, "idle"));
                    const response = utils_1.default.getResponse(message, { id });
                    await this.mqtt.publish("bridge/response/device/ota_update/unschedule", (0, json_stable_stringify_without_jsonify_1.default)(response));
                    break;
                }
            }
            this.#inProgress.delete(device.ieeeAddr);
        }
        if (error) {
            const response = utils_1.default.getResponse(message, {}, error);
            await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, (0, json_stable_stringify_without_jsonify_1.default)(response));
            logger_1.default.error(error);
            if (errorStack) {
                logger_1.default.debug(errorStack);
            }
        }
    }
    /**
     * Do the bulk of the update work (hand over to zigbee-herdsman, then re-interview).
     *
     * `dataSettings` object may be mutated by zigbee-herdsman to fit request (e.g. known manuf quirk)
     */
    async #update(source, device, requestPayload, requestTsn, dataSettings, endpoint) {
        const [from, to] = await device.zh.updateOta(source, requestPayload, requestTsn, device.otaExtraMetas, async (progress, remaining) => {
            await this.publishEntityState(device, this.#getEntityPublishPayload(device, "updating", progress, remaining));
        }, dataSettings, endpoint);
        if (to === undefined) {
            this.#removeProgressAndRemainingFromState(device);
            await this.publishEntityState(device, this.#getEntityPublishPayload(device, { available: false, current: from }));
            return [from.fileVersion, undefined];
        }
        logger_1.default.info(`Finished update of '${device.name}'`);
        this.#removeProgressAndRemainingFromState(device);
        await this.publishEntityState(device, this.#getEntityPublishPayload(device, { available: false, current: to }));
        logger_1.default.info(() => `Device '${device.name}' was OTA updated from '${from.fileVersion}' to '${to.fileVersion}'`);
        // OTA update can bring new features & co, force full re-interview and re-configure, same as a "device joined"
        if (device.zh.meta.configured !== undefined) {
            delete device.zh.meta.configured;
            device.zh.save();
        }
        setTimeout(() => {
            device.reInterview(this.eventBus).catch((error) => {
                logger_1.default.error(`${error.message}. Re-try manually after some time.`);
            });
        }, 5000);
        return [from.fileVersion, to.fileVersion];
    }
}
exports.default = OTAUpdate;
__decorate([
    bind_decorator_1.default
], OTAUpdate.prototype, "onZigbeeEvent", null);
__decorate([
    bind_decorator_1.default
], OTAUpdate.prototype, "onMQTTMessage", null);
//# sourceMappingURL=data:application/json;base64, 