"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.Device = exports.InterviewState = void 0;
const node_assert_1 = __importDefault(require("node:assert"));
const utils_1 = require("../../utils");
const logger_1 = require("../../utils/logger");
const timeService = __importStar(require("../../utils/timeService"));
const ZSpec = __importStar(require("../../zspec"));
const enums_1 = require("../../zspec/enums");
const Zcl = __importStar(require("../../zspec/zcl"));
const Zdo = __importStar(require("../../zspec/zdo"));
const ota_1 = require("../helpers/ota");
const zclTransactionSequenceNumber_1 = __importDefault(require("../helpers/zclTransactionSequenceNumber"));
const endpoint_1 = __importDefault(require("./endpoint"));
const entity_1 = __importDefault(require("./entity"));
const NS = "zh:controller:device";
const INTERVIEW_GENBASIC_ATTRIBUTES = [
    "modelId",
    "manufacturerName",
    "powerSource",
    "zclVersion",
    "appVersion",
    "stackVersion",
    "hwVersion",
    "dateCode",
    "swBuildId",
];
const GEN_BASIC_CLUSTER_ID = Zcl.Clusters.genBasic.ID;
const GEN_TIME_CLUSTER_ID = Zcl.Clusters.genTime.ID;
const GEN_POLL_CTRL_CLUSTER_ID = Zcl.Clusters.genPollCtrl.ID;
const GEN_OTA_CLUSTER_ID = Zcl.Clusters.genOta.ID;
var InterviewState;
(function (InterviewState) {
    InterviewState["Pending"] = "PENDING";
    InterviewState["InProgress"] = "IN_PROGRESS";
    InterviewState["Successful"] = "SUCCESSFUL";
    InterviewState["Failed"] = "FAILED";
})(InterviewState || (exports.InterviewState = InterviewState = {}));
class Device extends entity_1.default {
    // biome-ignore lint/style/useNamingConvention: cross-repo impact
    ID;
    #genBasic = {};
    _endpoints;
    _ieeeAddr;
    _interviewState;
    _lastSeen;
    _manufacturerID;
    _networkAddress;
    _type;
    _linkquality;
    _skipDefaultResponse;
    _customReadResponse;
    _lastDefaultResponseSequenceNumber;
    _checkinInterval;
    _pendingRequestTimeout;
    _customClusters = {};
    _gpSecurityKey;
    #scheduledOta;
    #otaInProgress = false;
    // Getters/setters
    get ieeeAddr() {
        return this._ieeeAddr;
    }
    set ieeeAddr(ieeeAddr) {
        this._ieeeAddr = ieeeAddr;
    }
    get applicationVersion() {
        return this.#genBasic.appVersion;
    }
    set applicationVersion(version) {
        this.#genBasic.appVersion = version;
    }
    get endpoints() {
        return this._endpoints;
    }
    get interviewState() {
        return this._interviewState;
    }
    get lastSeen() {
        return this._lastSeen;
    }
    get manufacturerID() {
        return this._manufacturerID;
    }
    get isDeleted() {
        return Device.deletedDevices.has(this.ieeeAddr);
    }
    set type(type) {
        this._type = type;
    }
    get type() {
        return this._type;
    }
    get dateCode() {
        return this.#genBasic.dateCode;
    }
    set dateCode(code) {
        this.#genBasic.dateCode = code;
    }
    set hardwareVersion(version) {
        this.#genBasic.hwVersion = version;
    }
    get hardwareVersion() {
        return this.#genBasic.hwVersion;
    }
    get manufacturerName() {
        return this.#genBasic.manufacturerName;
    }
    set manufacturerName(name) {
        this.#genBasic.manufacturerName = name;
    }
    set modelID(id) {
        this.#genBasic.modelId = id;
    }
    get modelID() {
        return this.#genBasic.modelId;
    }
    get networkAddress() {
        return this._networkAddress;
    }
    set networkAddress(networkAddress) {
        Device.nwkToIeeeCache.delete(this._networkAddress);
        this._networkAddress = networkAddress;
        Device.nwkToIeeeCache.set(this._networkAddress, this.ieeeAddr);
        for (const endpoint of this._endpoints) {
            endpoint.deviceNetworkAddress = networkAddress;
        }
    }
    get powerSource() {
        return this.#genBasic.powerSource ? Zcl.POWER_SOURCES[this.#genBasic.powerSource] : undefined;
    }
    set powerSource(source) {
        if (typeof source === "number") {
            this.#genBasic.powerSource = source & ~(1 << 7);
        }
        else {
            for (const key in Zcl.POWER_SOURCES) {
                const val = Zcl.POWER_SOURCES[key];
                if (val === source) {
                    this.#genBasic.powerSource = Number(key);
                    break;
                }
            }
        }
    }
    get softwareBuildID() {
        return this.#genBasic.swBuildId;
    }
    set softwareBuildID(id) {
        this.#genBasic.swBuildId = id;
    }
    get stackVersion() {
        return this.#genBasic.stackVersion;
    }
    set stackVersion(version) {
        this.#genBasic.stackVersion = version;
    }
    get zclVersion() {
        return this.#genBasic.zclVersion;
    }
    set zclVersion(version) {
        this.#genBasic.zclVersion = version;
    }
    get linkquality() {
        return this._linkquality;
    }
    set linkquality(linkquality) {
        this._linkquality = linkquality;
    }
    get skipDefaultResponse() {
        return this._skipDefaultResponse;
    }
    set skipDefaultResponse(skipDefaultResponse) {
        this._skipDefaultResponse = skipDefaultResponse;
    }
    get customReadResponse() {
        return this._customReadResponse;
    }
    set customReadResponse(customReadResponse) {
        this._customReadResponse = customReadResponse;
    }
    get checkinInterval() {
        return this._checkinInterval;
    }
    set checkinInterval(checkinInterval) {
        this._checkinInterval = checkinInterval;
        this.resetPendingRequestTimeout();
    }
    get pendingRequestTimeout() {
        return this._pendingRequestTimeout;
    }
    set pendingRequestTimeout(pendingRequestTimeout) {
        this._pendingRequestTimeout = pendingRequestTimeout;
    }
    get customClusters() {
        return this._customClusters;
    }
    get gpSecurityKey() {
        return this._gpSecurityKey;
    }
    get genBasic() {
        return this.#genBasic;
    }
    get scheduledOta() {
        return this.#scheduledOta;
    }
    get otaInProgress() {
        return this.#otaInProgress;
    }
    meta;
    // This lookup contains all devices that are queried from the database, this is to ensure that always
    // the same instance is returned.
    static devices = new Map();
    static loadedFromDatabase = false;
    static deletedDevices = new Map();
    static nwkToIeeeCache = new Map();
    constructor(id, type, ieeeAddr, networkAddress, manufacturerID, endpoints, manufacturerName, powerSource, modelID, applicationVersion, stackVersion, zclVersion, hardwareVersion, dateCode, softwareBuildID, interviewState, meta, lastSeen, checkinInterval, pendingRequestTimeout, gpSecurityKey, scheduledOta) {
        super();
        this.ID = id;
        this._type = type;
        this._ieeeAddr = ieeeAddr;
        this._networkAddress = networkAddress;
        this._manufacturerID = manufacturerID;
        this._endpoints = endpoints;
        this.#genBasic.manufacturerName = manufacturerName;
        this.powerSource = powerSource ?? Zcl.PowerSource.Unknown;
        this.#genBasic.modelId = modelID;
        this.#genBasic.appVersion = applicationVersion;
        this.#genBasic.stackVersion = stackVersion;
        this.#genBasic.zclVersion = zclVersion;
        this.#genBasic.hwVersion = hardwareVersion;
        this.#genBasic.dateCode = dateCode;
        this.#genBasic.swBuildId = softwareBuildID;
        this._interviewState = interviewState;
        this._skipDefaultResponse = false;
        this.meta = meta;
        this._lastSeen = lastSeen;
        this._checkinInterval = checkinInterval;
        this._pendingRequestTimeout = pendingRequestTimeout;
        this._gpSecurityKey = gpSecurityKey;
        this.#scheduledOta = scheduledOta;
    }
    createEndpoint(id) {
        if (this.getEndpoint(id)) {
            throw new Error(`Device '${this.ieeeAddr}' already has an endpoint '${id}'`);
        }
        const endpoint = endpoint_1.default.create(id, undefined, undefined, [], [], this.networkAddress, this.ieeeAddr);
        this.endpoints.push(endpoint);
        this.save();
        return endpoint;
    }
    changeIeeeAddress(ieeeAddr) {
        Device.devices.delete(this.ieeeAddr);
        this.ieeeAddr = ieeeAddr;
        Device.devices.set(this.ieeeAddr, this);
        Device.nwkToIeeeCache.set(this.networkAddress, this.ieeeAddr);
        for (const ep of this.endpoints) {
            ep.deviceIeeeAddress = ieeeAddr;
        }
        this.save();
    }
    getEndpoint(id) {
        return this.endpoints.find((e) => e.ID === id);
    }
    // There might be multiple endpoints with same DeviceId but it is not supported and first endpoint is returned
    getEndpointByDeviceType(deviceType) {
        const deviceID = Zcl.ENDPOINT_DEVICE_TYPE[deviceType];
        return this.endpoints.find((d) => d.deviceID === deviceID);
    }
    updateGenBasic(data) {
        Object.assign(this.#genBasic, data);
    }
    implicitCheckin() {
        // No need to do anythign in `catch` as `endpoint.sendRequest` already logs failures.
        Promise.allSettled(this.endpoints.map((e) => e.sendPendingRequests(false))).catch(() => { });
    }
    updateLastSeen() {
        this._lastSeen = Date.now();
    }
    resetPendingRequestTimeout() {
        // pendingRequestTimeout can be changed dynamically at runtime, and it is not persisted.
        // Default timeout is one checkin interval in milliseconds.
        this._pendingRequestTimeout = (this._checkinInterval ?? 0) * 1000;
    }
    hasPendingRequests() {
        return this.endpoints.find((e) => e.hasPendingRequests()) !== undefined;
    }
    async onZclData(dataPayload, frame, endpoint, defaultResponse) {
        if (!Device.devices.has(this.ieeeAddr)) {
            // prevent race conditions where device gets deleted during processing
            return;
        }
        if (this.type === "GreenPower") {
            // nothing below applies
            return;
        }
        const { header, command, cluster } = frame;
        let sendDefaultResponse = !header.frameControl.disableDefaultResponse && !dataPayload.wasBroadcast && command.response === undefined;
        let defaultResponseStatus = defaultResponse ?? Zcl.Status.SUCCESS;
        if (header.isGlobal) {
            // Response to read requests
            switch (command.name) {
                case "read": {
                    // NOTE: `sendDefaultResponse` always false from `command.response === 0x01`
                    if (this._customReadResponse?.(frame, endpoint)) {
                        break;
                    }
                    const endpointCache = dataPayload.clusterID === GEN_TIME_CLUSTER_ID
                        ? {
                            attributes: timeService.getTimeClusterAttributes(),
                        }
                        : endpoint.clusters[frame.cluster.name];
                    if (endpointCache !== undefined) {
                        const response = {};
                        for (const entry of frame.payload) {
                            // TODO: this.manufacturerID or frame.header.manufacturerCode
                            const name = Zcl.Utils.getClusterAttribute(cluster, entry.attrId, this.manufacturerID)?.name;
                            if (name !== undefined && name in endpointCache.attributes) {
                                response[name] = endpointCache.attributes[name];
                            }
                        }
                        try {
                            await endpoint.readResponse(cluster.ID, header.transactionSequenceNumber, response, {
                                srcEndpoint: dataPayload.destinationEndpoint,
                            });
                        }
                        catch (error) {
                            logger_1.logger.error(`Read response to ${this.ieeeAddr} failed (${error.message})`, NS);
                            // XXX: technically, if `readResponse` fails before reaching the network (internal to ZH), we should send a default response
                            //      currently not possible due to implementation (no distinction as to "where" it failed)
                        }
                    }
                    break;
                }
                case "defaultRsp": {
                    sendDefaultResponse = false; // per spec
                    break;
                }
            }
        }
        else if (header.isSpecific) {
            switch (cluster.name) {
                case "ssIasZone": {
                    if (command.name === "enrollReq") {
                        // Respond to enroll requests
                        logger_1.logger.debug(`IAS - '${this.ieeeAddr}' responding to enroll response`, NS);
                        try {
                            await endpoint.command("ssIasZone", "enrollRsp", { enrollrspcode: 0, zoneid: 23 }, { disableDefaultResponse: true });
                            sendDefaultResponse = false; // per spec, sending a specific response TODO: no "Effect on receipt" in spec, is this correct?
                        }
                        catch (error) {
                            logger_1.logger.error(`Handling of IAS zone enroll for ${this.ieeeAddr} failed (${error.message})`, NS);
                            defaultResponseStatus = Zcl.Status.FAILURE;
                        }
                    }
                    break;
                }
                case "genPollCtrl": {
                    if (command.name === "checkin") {
                        let startedFastPolling = false;
                        // Handle check-in from sleeping end devices
                        try {
                            if (this.hasPendingRequests() || this._checkinInterval === undefined) {
                                logger_1.logger.debug(`check-in from ${this.ieeeAddr}: accepting fast-poll`, NS);
                                await endpoint.command(cluster.name, "checkinRsp", { startFastPolling: 1, fastPollTimeout: 0 }, { sendPolicy: "immediate" });
                                startedFastPolling = true;
                                // This is a good time to read the checkin interval if we haven't stored it previously
                                if (this._checkinInterval === undefined) {
                                    const pollPeriod = await endpoint.read("genPollCtrl", ["checkinInterval"], { sendPolicy: "immediate" });
                                    this._checkinInterval = pollPeriod.checkinInterval / 4; // convert to seconds
                                    this.resetPendingRequestTimeout();
                                    logger_1.logger.debug(`Request Queue (${this.ieeeAddr}): default expiration timeout set to ${this.pendingRequestTimeout}`, NS);
                                }
                                await Promise.all(this.endpoints.map(async (e) => await e.sendPendingRequests(true)));
                            }
                            else {
                                logger_1.logger.debug(`check-in from ${this.ieeeAddr}: declining fast-poll`, NS);
                                await endpoint.command(cluster.name, "checkinRsp", { startFastPolling: 0, fastPollTimeout: 0 }, { sendPolicy: "immediate" });
                            }
                            sendDefaultResponse = false; // per spec, sending a specific response
                        }
                        catch (error) {
                            logger_1.logger.error(`Handling of poll check-in from ${this.ieeeAddr} failed (${error.message})`, NS);
                            defaultResponseStatus = Zcl.Status.FAILURE;
                        }
                        finally {
                            if (startedFastPolling) {
                                // We *must* end fast-poll when we're done sending things. Otherwise we cause undue power-drain.
                                logger_1.logger.debug(`check-in from ${this.ieeeAddr}: stopping fast-poll`, NS);
                                await endpoint.command(cluster.name, "fastPollStop", {}, { sendPolicy: "immediate" });
                            }
                        }
                    }
                    break;
                }
            }
        }
        // Send a default response if necessary.
        /* v8 ignore next */
        const disableTuyaDefaultResponse = this.manufacturerName?.startsWith("_TZ") && process.env.DISABLE_TUYA_DEFAULT_RESPONSE;
        // Sometimes messages are received twice, prevent responding twice
        const alreadyResponded = this._lastDefaultResponseSequenceNumber === header.transactionSequenceNumber;
        if (!this._skipDefaultResponse && sendDefaultResponse && !alreadyResponded && !disableTuyaDefaultResponse) {
            try {
                this._lastDefaultResponseSequenceNumber = header.transactionSequenceNumber;
                // In the ZCL it is not documented what the direction of the default response should be
                // In https://github.com/Koenkk/zigbee2mqtt/issues/18096 a commandResponse (SERVER_TO_CLIENT)
                // is send and the device expects a CLIENT_TO_SERVER back.
                // Previously SERVER_TO_CLIENT was always used.
                // Therefore for non-global commands we inverse the direction.
                const direction = header.isGlobal
                    ? Zcl.Direction.SERVER_TO_CLIENT
                    : header.frameControl.direction === Zcl.Direction.CLIENT_TO_SERVER
                        ? Zcl.Direction.SERVER_TO_CLIENT
                        : Zcl.Direction.CLIENT_TO_SERVER;
                await endpoint.defaultResponse(command.ID, defaultResponseStatus, cluster.ID, header.transactionSequenceNumber, {
                    direction,
                });
            }
            catch (error) {
                logger_1.logger.debug(`Default response to ${this.ieeeAddr} failed (${error})`, NS);
            }
        }
    }
    /*
     * CRUD
     */
    /**
     * Reset runtime lookups.
     */
    static resetCache() {
        Device.devices.clear();
        Device.loadedFromDatabase = false;
        Device.deletedDevices.clear();
        Device.nwkToIeeeCache.clear();
    }
    static fromDatabaseEntry(entry) {
        const networkAddress = entry.nwkAddr;
        const ieeeAddr = entry.ieeeAddr;
        const endpoints = [];
        for (const id in entry.endpoints) {
            endpoints.push(endpoint_1.default.fromDatabaseRecord(entry.endpoints[id], networkAddress, ieeeAddr));
        }
        const meta = entry.meta ?? {};
        if (entry.type === "Group") {
            throw new Error("Cannot load device from group");
        }
        // default: no timeout (messages expire immediately after first send attempt)
        let pendingRequestTimeout = 0;
        if (endpoints.filter((e) => e.inputClusters.includes(GEN_POLL_CTRL_CLUSTER_ID)).length > 0) {
            // default for devices that support genPollCtrl cluster (RX off when idle): 1 day
            pendingRequestTimeout = 86400000;
        }
        // always load value from database available (modernExtend.quirkCheckinInterval() exists for devices without genPollCtl)
        if (entry.checkinInterval !== undefined) {
            // if the checkin interval is known, messages expire by default after one checkin interval
            pendingRequestTimeout = entry.checkinInterval * 1000; // milliseconds
        }
        logger_1.logger.debug(`Request Queue (${ieeeAddr}): default expiration timeout set to ${pendingRequestTimeout}`, NS);
        // Migrate interviewCompleted to interviewState
        if (!entry.interviewState) {
            entry.interviewState = entry.interviewCompleted ? InterviewState.Successful : InterviewState.Failed;
            logger_1.logger.debug(`Migrated interviewState for '${ieeeAddr}': ${entry.interviewCompleted} -> ${entry.interviewState}`, NS);
        }
        return new Device(entry.id, entry.type, ieeeAddr, networkAddress, entry.manufId, endpoints, entry.manufName, entry.powerSource, entry.modelId, entry.appVersion, entry.stackVersion, entry.zclVersion, entry.hwVersion, entry.dateCode, entry.swBuildId, entry.interviewState, meta, entry.lastSeen, entry.checkinInterval, pendingRequestTimeout, entry.gpSecurityKey, entry.scheduledOta);
    }
    toDatabaseEntry() {
        const epList = this.endpoints.map((e) => e.ID);
        const endpoints = {};
        for (const endpoint of this.endpoints) {
            endpoints[endpoint.ID] = endpoint.toDatabaseRecord();
        }
        return {
            id: this.ID,
            type: this.type,
            ieeeAddr: this.ieeeAddr,
            nwkAddr: this.networkAddress,
            manufId: this.manufacturerID,
            manufName: this.manufacturerName,
            powerSource: this.powerSource,
            modelId: this.modelID,
            epList,
            endpoints,
            appVersion: this.applicationVersion,
            stackVersion: this.stackVersion,
            hwVersion: this.hardwareVersion,
            dateCode: this.dateCode,
            swBuildId: this.softwareBuildID,
            zclVersion: this.zclVersion,
            /** @deprecated Keep interviewCompleted for backwards compatibility (in case zh gets downgraded) */
            interviewCompleted: this.interviewState === InterviewState.Successful,
            interviewState: this.interviewState === InterviewState.InProgress ? InterviewState.Pending : this.interviewState,
            meta: this.meta,
            lastSeen: this.lastSeen,
            checkinInterval: this.checkinInterval,
            gpSecurityKey: this.gpSecurityKey,
            scheduledOta: this.scheduledOta,
        };
    }
    save(writeDatabase = true) {
        entity_1.default.database.update(this.toDatabaseEntry(), writeDatabase);
    }
    static loadFromDatabaseIfNecessary() {
        if (!Device.loadedFromDatabase) {
            for (const entry of entity_1.default.database.getEntriesIterator(["Coordinator", "EndDevice", "Router", "GreenPower", "Unknown"])) {
                const device = Device.fromDatabaseEntry(entry);
                Device.devices.set(device.ieeeAddr, device);
                Device.nwkToIeeeCache.set(device.networkAddress, device.ieeeAddr);
            }
            Device.loadedFromDatabase = true;
        }
    }
    static find(ieeeOrNwkAddress, includeDeleted = false) {
        return typeof ieeeOrNwkAddress === "string"
            ? Device.byIeeeAddr(ieeeOrNwkAddress, includeDeleted)
            : Device.byNetworkAddress(ieeeOrNwkAddress, includeDeleted);
    }
    static byIeeeAddr(ieeeAddr, includeDeleted = false) {
        Device.loadFromDatabaseIfNecessary();
        return includeDeleted ? (Device.deletedDevices.get(ieeeAddr) ?? Device.devices.get(ieeeAddr)) : Device.devices.get(ieeeAddr);
    }
    static byNetworkAddress(networkAddress, includeDeleted = false) {
        Device.loadFromDatabaseIfNecessary();
        const ieeeAddr = Device.nwkToIeeeCache.get(networkAddress);
        return ieeeAddr ? Device.byIeeeAddr(ieeeAddr, includeDeleted) : undefined;
    }
    static byType(type) {
        const devices = [];
        for (const device of Device.allIterator((d) => d.type === type)) {
            devices.push(device);
        }
        return devices;
    }
    /** Check if a device is explicitly deleted */
    static isDeletedByIeeeAddr(ieeeAddr) {
        Device.loadFromDatabaseIfNecessary();
        return Device.deletedDevices.has(ieeeAddr);
    }
    /** Check if a device is explicitly deleted */
    static isDeletedByNetworkAddress(networkAddress) {
        Device.loadFromDatabaseIfNecessary();
        const ieeeAddr = Device.nwkToIeeeCache.get(networkAddress);
        return ieeeAddr ? Device.deletedDevices.has(ieeeAddr) : false;
    }
    /**
     * @deprecated use allIterator()
     */
    static all() {
        Device.loadFromDatabaseIfNecessary();
        return Array.from(Device.devices.values());
    }
    static *allIterator(predicate) {
        Device.loadFromDatabaseIfNecessary();
        for (const device of Device.devices.values()) {
            if (!predicate || predicate(device)) {
                yield device;
            }
        }
    }
    undelete() {
        if (Device.deletedDevices.delete(this.ieeeAddr)) {
            Device.devices.set(this.ieeeAddr, this);
            entity_1.default.database.insert(this.toDatabaseEntry());
        }
        else {
            throw new Error(`Device '${this.ieeeAddr}' is not deleted`);
        }
    }
    static create(type, ieeeAddr, networkAddress, manufacturerID, manufacturerName, powerSource, modelID, interviewState, gpSecurityKey) {
        Device.loadFromDatabaseIfNecessary();
        if (Device.devices.has(ieeeAddr)) {
            throw new Error(`Device with IEEE address '${ieeeAddr}' already exists`);
        }
        const ID = entity_1.default.database.newID();
        const device = new Device(ID, type, ieeeAddr, networkAddress, manufacturerID, [], manufacturerName, powerSource, modelID, undefined, undefined, undefined, undefined, undefined, undefined, interviewState, {}, undefined, undefined, 0, gpSecurityKey, undefined);
        entity_1.default.database.insert(device.toDatabaseEntry());
        Device.devices.set(device.ieeeAddr, device);
        Device.nwkToIeeeCache.set(device.networkAddress, device.ieeeAddr);
        return device;
    }
    /*
     * Zigbee functions
     */
    async interview(ignoreCache = false) {
        if (this.interviewState === InterviewState.InProgress) {
            const message = `Interview - interview already in progress for '${this.ieeeAddr}'`;
            logger_1.logger.debug(message, NS);
            throw new Error(message);
        }
        let err;
        this._interviewState = InterviewState.InProgress;
        logger_1.logger.debug(`Interview - start device '${this.ieeeAddr}'`, NS);
        try {
            await this.interviewInternal(ignoreCache);
            logger_1.logger.debug(`Interview - completed for device '${this.ieeeAddr}'`, NS);
            this._interviewState = InterviewState.Successful;
        }
        catch (error) {
            if (this.interviewQuirks()) {
                this._interviewState = InterviewState.Successful;
                logger_1.logger.debug(`Interview - completed for device '${this.ieeeAddr}' because of quirks ('${error}')`, NS);
            }
            else {
                this._interviewState = InterviewState.Failed;
                logger_1.logger.debug(`Interview - failed for device '${this.ieeeAddr}' with error '${error}'`, NS);
                err = error;
            }
        }
        finally {
            this.save();
        }
        if (err) {
            throw err;
        }
    }
    interviewQuirks() {
        logger_1.logger.debug(`Interview - quirks check for '${this.modelID}'-'${this.manufacturerName}'-'${this.type}'`, NS);
        // Tuya devices are typically hard to interview. They also don't require a full interview to work correctly
        // e.g. no ias enrolling is required for the devices to work.
        // Assume that in case we got both the manufacturerName and modelID the device works correctly.
        // https://github.com/Koenkk/zigbee2mqtt/issues/7564:
        //      Fails during ias enroll due to UNSUPPORTED_ATTRIBUTE
        // https://github.com/Koenkk/zigbee2mqtt/issues/4655
        //      Device does not change zoneState after enroll (event with original gateway)
        // modelID is mostly in the form of e.g. TS0202 and manufacturerName like e.g. _TYZB01_xph99wvr
        if (this.manufacturerName === "HOBEIAN" ||
            (this.modelID?.match("^TS\\d*$") && (this.manufacturerName?.match("^_TZ.*_.*$") || this.manufacturerName?.match("^_TYZB01_.*$")))) {
            this.#genBasic.powerSource = this.#genBasic.powerSource || Zcl.PowerSource.Battery;
            logger_1.logger.debug("Interview - quirks matched for Tuya end device", NS);
            return true;
        }
        // Some devices, e.g. Xiaomi end devices have a different interview procedure, after pairing they
        // report it's modelID trough a readResponse. The readResponse is received by the controller and set
        // on the device.
        const lookup = {
            "^3R.*?Z": {
                type: "EndDevice",
                powerSource: Zcl.PowerSource.Battery,
            },
            "lumi..*": {
                type: "EndDevice",
                manufacturerID: 4151,
                manufacturerName: "LUMI",
                powerSource: Zcl.PowerSource.Battery,
            },
            "TERNCY-PP01": {
                type: "EndDevice",
                manufacturerID: 4648,
                manufacturerName: "TERNCY",
                powerSource: Zcl.PowerSource.Battery,
            },
            "3RWS18BZ": {}, // https://github.com/Koenkk/zigbee-herdsman-converters/pull/2710
            "MULTI-MECI--EA01": {},
            MOT003: {}, // https://github.com/Koenkk/zigbee2mqtt/issues/12471
            "C-ZB-SEDC": {}, //candeo device that doesn't follow IAS enrollment process correctly and therefore fails to complete interview
            "C-ZB-SEMO": {}, //candeo device that doesn't follow IAS enrollment process correctly and therefore fails to complete interview
            "CS-T9C-A0-BG": {}, // iAS enroll fails: https://github.com/Koenkk/zigbee2mqtt/issues/27822
            "SNZB-01": {}, // iAS enroll fails: https://github.com/Koenkk/zigbee2mqtt/issues/29474
        };
        let match;
        for (const key in lookup) {
            if (this.modelID?.match(key)) {
                match = key;
                break;
            }
        }
        if (match) {
            const info = lookup[match];
            logger_1.logger.debug(`Interview procedure failed but got modelID matching '${match}', assuming interview succeeded`, NS);
            this._type = this._type === "Unknown" && info.type ? info.type : this._type;
            this._manufacturerID = this._manufacturerID || info.manufacturerID;
            this.#genBasic.manufacturerName = this.#genBasic.manufacturerName || info.manufacturerName;
            this.#genBasic.powerSource = (this.#genBasic.powerSource || info.powerSource) /* v8 ignore next */ ?? Zcl.PowerSource.Unknown;
            logger_1.logger.debug(`Interview - quirks matched on '${match}'`, NS);
            return true;
        }
        logger_1.logger.debug("Interview - quirks did not match", NS);
        return false;
    }
    async interviewInternal(ignoreCache) {
        const hasNodeDescriptor = () => this._manufacturerID !== undefined && this._type !== "Unknown";
        if (ignoreCache || !hasNodeDescriptor()) {
            for (let attempt = 0; attempt < 6; attempt++) {
                try {
                    await this.updateNodeDescriptor();
                    break;
                }
                catch (error) {
                    if (this.interviewQuirks()) {
                        logger_1.logger.debug(`Interview - completed for device '${this.ieeeAddr}' because of quirks ('${error}')`, NS);
                        return;
                    }
                    // Most of the times the first node descriptor query fails and the seconds one succeeds.
                    logger_1.logger.debug(`Interview - node descriptor request failed for '${this.ieeeAddr}', attempt ${attempt + 1}`, NS);
                }
            }
        }
        else {
            logger_1.logger.debug(`Interview - skip node descriptor request for '${this.ieeeAddr}', already got it`, NS);
        }
        if (!hasNodeDescriptor()) {
            throw new Error(`Interview failed because can not get node descriptor ('${this.ieeeAddr}')`);
        }
        if (this.manufacturerID === 4619 && this._type === "EndDevice") {
            // Give Tuya end device some time to pair. Otherwise they leave immediately.
            // https://github.com/Koenkk/zigbee2mqtt/issues/5814
            logger_1.logger.debug("Interview - Detected Tuya end device, waiting 10 seconds...", NS);
            await (0, utils_1.wait)(10000);
        }
        else if (this.manufacturerID === 0 || this.manufacturerID === 4098) {
            // Potentially a Tuya device, some sleep fast so make sure to read the modelId and manufacturerName quickly.
            // In case the device responds, the endoint and modelID/manufacturerName are set
            // in controller.onZclOrRawData()
            // https://github.com/Koenkk/zigbee2mqtt/issues/7553
            logger_1.logger.debug("Interview - Detected potential Tuya end device, reading modelID and manufacturerName...", NS);
            try {
                const endpoint = endpoint_1.default.create(1, undefined, undefined, [], [], this.networkAddress, this.ieeeAddr);
                const result = await endpoint.read("genBasic", ["modelId", "manufacturerName"], { sendPolicy: "immediate" });
                this.updateGenBasic(result);
            }
            catch (error) {
                logger_1.logger.debug(`Interview - Tuya read modelID and manufacturerName failed (${error})`, NS);
            }
        }
        // e.g. Xiaomi Aqara Opple devices fail to respond to the first active endpoints request, therefore try 2 times
        // https://github.com/Koenkk/zigbee-herdsman/pull/103
        let gotActiveEndpoints = false;
        for (let attempt = 0; attempt < 2; attempt++) {
            try {
                await this.updateActiveEndpoints();
                gotActiveEndpoints = true;
                break;
            }
            catch (error) {
                logger_1.logger.debug(`Interview - active endpoints request failed for '${this.ieeeAddr}', attempt ${attempt + 1} (${error})`, NS);
            }
        }
        if (!gotActiveEndpoints) {
            throw new Error(`Interview failed because can not get active endpoints ('${this.ieeeAddr}')`);
        }
        logger_1.logger.debug(`Interview - got active endpoints for device '${this.ieeeAddr}'`, NS);
        const coordinator = Device.byType("Coordinator")[0];
        for (const endpoint of this._endpoints) {
            await endpoint.updateSimpleDescriptor();
            logger_1.logger.debug(`Interview - got simple descriptor for endpoint '${endpoint.ID}' device '${this.ieeeAddr}'`, NS);
            // Read attributes
            // nice to have but not required for successful pairing as most of the attributes are not mandatory in ZCL specification
            if (endpoint.supportsInputCluster("genBasic")) {
                for (const key of INTERVIEW_GENBASIC_ATTRIBUTES) {
                    if (ignoreCache || !this.#genBasic[key]) {
                        try {
                            let result;
                            try {
                                result = await endpoint.read("genBasic", [key], { sendPolicy: "immediate" });
                            }
                            catch (error) {
                                // Reading attributes can fail for many reason, e.g. it could be that device rejoins
                                // while joining like in:
                                // https://github.com/Koenkk/zigbee-herdsman-converters/issues/2485.
                                // The modelID and manufacturerName are crucial for device identification, so retry.
                                if (key === "modelId" || key === "manufacturerName") {
                                    logger_1.logger.debug(`Interview - first ${key} retrieval attempt failed, retrying after 10 seconds...`, NS);
                                    await (0, utils_1.wait)(10000);
                                    result = await endpoint.read("genBasic", [key], { sendPolicy: "immediate" });
                                }
                                else {
                                    throw error;
                                }
                            }
                            this.updateGenBasic(result);
                            logger_1.logger.debug(`Interview - got '${key}' for device '${this.ieeeAddr}'`, NS);
                        }
                        catch (error) {
                            logger_1.logger.debug(`Interview - failed to read attribute '${key}' from endpoint '${endpoint.ID}' (${error})`, NS);
                        }
                    }
                }
            }
            // Enroll IAS device
            if (endpoint.supportsInputCluster("ssIasZone")) {
                logger_1.logger.debug(`Interview - IAS - enrolling '${this.ieeeAddr}' endpoint '${endpoint.ID}'`, NS);
                const stateBefore = await endpoint.read("ssIasZone", ["iasCieAddr", "zoneState"], { sendPolicy: "immediate" });
                logger_1.logger.debug(() => `Interview - IAS - before enrolling state: '${JSON.stringify(stateBefore)}'`, NS);
                // Do not enroll when device has already been enrolled
                if (stateBefore.zoneState !== 1 || stateBefore.iasCieAddr !== coordinator.ieeeAddr) {
                    logger_1.logger.debug("Interview - IAS - not enrolled, enrolling", NS);
                    await endpoint.write("ssIasZone", { iasCieAddr: coordinator.ieeeAddr }, { sendPolicy: "immediate" });
                    logger_1.logger.debug("Interview - IAS - wrote iasCieAddr", NS);
                    // There are 2 enrollment procedures:
                    // - Auto enroll: coordinator has to send enrollResponse without receiving an enroll request
                    //                this case is handled below.
                    // - Manual enroll: coordinator replies to enroll request with an enroll response.
                    //                  this case in hanled in onZclData().
                    // https://github.com/Koenkk/zigbee2mqtt/issues/4569#issuecomment-706075676
                    await (0, utils_1.wait)(500);
                    logger_1.logger.debug(`IAS - '${this.ieeeAddr}' sending enroll response (auto enroll)`, NS);
                    const payload = { enrollrspcode: 0, zoneid: 23 };
                    await endpoint.command("ssIasZone", "enrollRsp", payload, { disableDefaultResponse: true, sendPolicy: "immediate" });
                    let enrolled = false;
                    for (let attempt = 0; attempt < 20; attempt++) {
                        await (0, utils_1.wait)(500);
                        const stateAfter = await endpoint.read("ssIasZone", ["iasCieAddr", "zoneState"], { sendPolicy: "immediate" });
                        logger_1.logger.debug(() => `Interview - IAS - after enrolling state (${attempt}): '${JSON.stringify(stateAfter)}'`, NS);
                        if (stateAfter.zoneState === 1) {
                            enrolled = true;
                            break;
                        }
                    }
                    if (enrolled) {
                        logger_1.logger.debug(`Interview - IAS successfully enrolled '${this.ieeeAddr}' endpoint '${endpoint.ID}'`, NS);
                    }
                    else {
                        throw new Error(`Interview failed because of failed IAS enroll (zoneState didn't change ('${this.ieeeAddr}')`);
                    }
                }
                else {
                    logger_1.logger.debug("Interview - IAS - already enrolled, skipping enroll", NS);
                }
            }
        }
        // Bind poll control
        try {
            for (const endpoint of this.endpoints.filter((e) => e.supportsInputCluster("genPollCtrl"))) {
                logger_1.logger.debug(`Interview - Poll control - binding '${this.ieeeAddr}' endpoint '${endpoint.ID}'`, NS);
                await endpoint.bind("genPollCtrl", coordinator.endpoints[0]);
                const pollPeriod = await endpoint.read("genPollCtrl", ["checkinInterval"], { sendPolicy: "immediate" });
                this._checkinInterval = pollPeriod.checkinInterval / 4; // convert to seconds
                this.resetPendingRequestTimeout();
            }
            /* v8 ignore start */
        }
        catch (error) {
            logger_1.logger.debug(`Interview - failed to bind genPollCtrl (${error})`, NS);
        }
        /* v8 ignore stop */
    }
    async updateNodeDescriptor() {
        const clusterId = Zdo.ClusterId.NODE_DESCRIPTOR_REQUEST;
        const zdoPayload = Zdo.Buffalo.buildRequest(entity_1.default.adapter.hasZdoMessageOverhead, clusterId, this.networkAddress);
        const response = await entity_1.default.adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false);
        if (!Zdo.Buffalo.checkStatus(response)) {
            throw new Zdo.StatusError(response[0]);
        }
        // TODO: make use of: capabilities.rxOnWhenIdle, maxIncTxSize, maxOutTxSize, serverMask.stackComplianceRevision
        const nodeDescriptor = response[1];
        this._manufacturerID = nodeDescriptor.manufacturerCode;
        switch (nodeDescriptor.logicalType) {
            case 0x0:
                this._type = "Coordinator";
                break;
            case 0x1:
                this._type = "Router";
                break;
            case 0x2:
                this._type = "EndDevice";
                break;
        }
        logger_1.logger.debug(`Interview - got node descriptor for device '${this.ieeeAddr}'`, NS);
        // TODO: define a property on Device for this value (would be good to have it displayed)
        // log for devices older than 1 from current revision
        if (nodeDescriptor.serverMask.stackComplianceRevision < ZSpec.ZIGBEE_REVISION - 1) {
            // always 0 before revision 21 where field was added
            const rev = nodeDescriptor.serverMask.stackComplianceRevision < 21 ? "pre-21" : nodeDescriptor.serverMask.stackComplianceRevision;
            logger_1.logger.info(`Device '${this.ieeeAddr}' is only compliant to revision '${rev}' of the Zigbee specification (current revision: ${ZSpec.ZIGBEE_REVISION}).`, NS);
        }
    }
    async updateActiveEndpoints() {
        const clusterId = Zdo.ClusterId.ACTIVE_ENDPOINTS_REQUEST;
        const zdoPayload = Zdo.Buffalo.buildRequest(entity_1.default.adapter.hasZdoMessageOverhead, clusterId, this.networkAddress);
        const response = await entity_1.default.adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false);
        if (!Zdo.Buffalo.checkStatus(response)) {
            throw new Zdo.StatusError(response[0]);
        }
        const activeEndpoints = response[1];
        // Make sure that the endpoint are sorted.
        activeEndpoints.endpointList.sort((a, b) => a - b);
        for (const endpoint of activeEndpoints.endpointList) {
            // Some devices, e.g. TERNCY return endpoint 0 in the active endpoints request.
            // This is not a valid endpoint number according to the ZCL, requesting a simple descriptor will result
            // into an error. Therefore we filter it, more info: https://github.com/Koenkk/zigbee-herdsman/issues/82
            if (endpoint !== 0 && !this.getEndpoint(endpoint)) {
                this._endpoints.push(endpoint_1.default.create(endpoint, undefined, undefined, [], [], this.networkAddress, this.ieeeAddr));
            }
        }
        // Remove disappeared endpoints (can happen with e.g. custom devices).
        this._endpoints = this._endpoints.filter((e) => activeEndpoints.endpointList.includes(e.ID));
    }
    /**
     * Request device to advertise its network address.
     * Note: This does not actually update the device property (if needed), as this is already done with `zdoResponse` event in Controller.
     */
    async requestNetworkAddress() {
        const clusterId = Zdo.ClusterId.NETWORK_ADDRESS_REQUEST;
        const zdoPayload = Zdo.Buffalo.buildRequest(entity_1.default.adapter.hasZdoMessageOverhead, clusterId, this.ieeeAddr, false, 0);
        await entity_1.default.adapter.sendZdo(this.ieeeAddr, ZSpec.BroadcastAddress.RX_ON_WHEN_IDLE, clusterId, zdoPayload, true);
    }
    async removeFromNetwork() {
        if (this._type === "GreenPower") {
            const payload = {
                options: 0x002550,
                srcID: Number(this.ieeeAddr),
            };
            const frame = Zcl.Frame.create(Zcl.FrameType.SPECIFIC, Zcl.Direction.SERVER_TO_CLIENT, true, undefined, zclTransactionSequenceNumber_1.default.next(), "pairing", 33, payload, this.customClusters);
            await entity_1.default.adapter.sendZclFrameToAll(242, frame, 242, enums_1.BroadcastAddress.RX_ON_WHEN_IDLE);
        }
        else {
            const clusterId = Zdo.ClusterId.LEAVE_REQUEST;
            const zdoPayload = Zdo.Buffalo.buildRequest(entity_1.default.adapter.hasZdoMessageOverhead, clusterId, this.ieeeAddr, Zdo.LeaveRequestFlags.WITHOUT_REJOIN);
            const response = await entity_1.default.adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false);
            if (!Zdo.Buffalo.checkStatus(response)) {
                throw new Zdo.StatusError(response[0]);
            }
        }
        this.removeFromDatabase();
    }
    removeFromDatabase() {
        Device.loadFromDatabaseIfNecessary();
        for (const endpoint of this.endpoints) {
            endpoint.removeFromAllGroupsDatabase();
        }
        if (entity_1.default.database.has(this.ID)) {
            entity_1.default.database.remove(this.ID);
        }
        Device.deletedDevices.set(this.ieeeAddr, this);
        Device.devices.delete(this.ieeeAddr);
        // Clear all data in case device joins again
        // Green power devices are never interviewed, keep existing interview state.
        this._interviewState = this.type === "GreenPower" ? this._interviewState : InterviewState.Pending;
        this.meta = {};
        const newEndpoints = [];
        for (const endpoint of this.endpoints) {
            newEndpoints.push(endpoint_1.default.create(endpoint.ID, endpoint.profileID, endpoint.deviceID, endpoint.inputClusters, endpoint.outputClusters, this.networkAddress, this.ieeeAddr));
        }
        this._endpoints = newEndpoints;
    }
    async lqi() {
        const clusterId = Zdo.ClusterId.LQI_TABLE_REQUEST;
        const table = [];
        const request = async (startIndex) => {
            const zdoPayload = Zdo.Buffalo.buildRequest(entity_1.default.adapter.hasZdoMessageOverhead, clusterId, startIndex);
            const response = await entity_1.default.adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false);
            if (!Zdo.Buffalo.checkStatus(response)) {
                throw new Zdo.StatusError(response[0]);
            }
            const result = response[1];
            table.push(...result.entryList);
            return [result.neighborTableEntries, result.entryList.length];
        };
        let [tableEntries, entryCount] = await request(0);
        const size = tableEntries;
        let nextStartIndex = entryCount;
        while (table.length < size) {
            [tableEntries, entryCount] = await request(nextStartIndex);
            nextStartIndex += entryCount;
        }
        return table;
    }
    async routingTable() {
        const clusterId = Zdo.ClusterId.ROUTING_TABLE_REQUEST;
        const table = [];
        const request = async (startIndex) => {
            const zdoPayload = Zdo.Buffalo.buildRequest(entity_1.default.adapter.hasZdoMessageOverhead, clusterId, startIndex);
            const response = await entity_1.default.adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false);
            if (!Zdo.Buffalo.checkStatus(response)) {
                throw new Zdo.StatusError(response[0]);
            }
            const result = response[1];
            table.push(...result.entryList);
            return [result.routingTableEntries, result.entryList.length];
        };
        let [tableEntries, entryCount] = await request(0);
        const size = tableEntries;
        let nextStartIndex = entryCount;
        while (table.length < size) {
            [tableEntries, entryCount] = await request(nextStartIndex);
            nextStartIndex += entryCount;
        }
        return table;
    }
    async bindingTable() {
        const clusterId = Zdo.ClusterId.BINDING_TABLE_REQUEST;
        const table = [];
        const request = async (startIndex) => {
            const zdoPayload = Zdo.Buffalo.buildRequest(entity_1.default.adapter.hasZdoMessageOverhead, clusterId, startIndex);
            const response = await entity_1.default.adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false);
            if (!Zdo.Buffalo.checkStatus(response)) {
                throw new Zdo.StatusError(response[0]);
            }
            const result = response[1];
            table.push(...result.entryList);
            return [result.bindingTableEntries, result.entryList.length];
        };
        let [tableEntries, entryCount] = await request(0);
        const size = tableEntries;
        let nextStartIndex = entryCount;
        while (table.length < size) {
            [tableEntries, entryCount] = await request(nextStartIndex);
            nextStartIndex += entryCount;
        }
        for (const ep of this._endpoints) {
            const newBinds = [];
            for (const entry of table) {
                if (entry.sourceEui64 !== this.ieeeAddr || entry.sourceEndpoint !== ep.ID) {
                    continue;
                }
                if (entry.destAddrMode === 0x01) {
                    newBinds.push({ type: "group", cluster: entry.clusterId, groupID: entry.dest });
                }
                else {
                    newBinds.push({
                        type: "endpoint",
                        cluster: entry.clusterId,
                        deviceIeeeAddress: entry.dest,
                        endpointID: entry.destEndpoint,
                    });
                }
            }
            ep.saveBindings(newBinds);
        }
        return table;
    }
    /**
     * Clear all the bindings of a device.
     * Support of this command is optional (only mandatory if device has a binding table).
     * @param eui64List list of bind entries to match and clear. Send `["0xffffffffffffffff"]` to clear all.
     */
    async clearAllBindings(eui64List) {
        const clusterId = Zdo.ClusterId.CLEAR_ALL_BINDINGS_REQUEST;
        const zdoPayload = Zdo.Buffalo.buildRequest(entity_1.default.adapter.hasZdoMessageOverhead, clusterId, { eui64List });
        const response = await entity_1.default.adapter.sendZdo(this.ieeeAddr, this.networkAddress, clusterId, zdoPayload, false);
        if (!Zdo.Buffalo.checkStatus(response)) {
            throw new Zdo.StatusError(response[0]);
        }
        if ((eui64List.length === 1 && eui64List[0].toLowerCase() === ZSpec.BLANK_EUI64) ||
            eui64List.some((eui64) => eui64.toLowerCase() === this.ieeeAddr)) {
            for (const ep of this._endpoints) {
                ep.clearBindings();
            }
        }
    }
    async ping(disableRecovery = true) {
        // Zigbee does not have an official pinging mechanism. Use a read request
        // of a mandatory basic cluster attribute to keep it as lightweight as
        // possible.
        const endpoint = this.endpoints.find((ep) => ep.inputClusters.includes(GEN_BASIC_CLUSTER_ID)) ?? this.endpoints[0];
        await endpoint.read("genBasic", ["zclVersion"], { disableRecovery, sendPolicy: "immediate" });
    }
    addCustomCluster(name, cluster) {
        (0, node_assert_1.default)(cluster.ID !== Zcl.Clusters.touchlink.ID && cluster.ID !== Zcl.Clusters.greenPower.ID, "Overriding of greenPower or touchlink cluster is not supported");
        if (Zcl.Utils.isClusterName(name)) {
            // Extend existing cluster
            const existingCluster = this._customClusters[name] ?? Zcl.Clusters[name];
            (0, node_assert_1.default)(existingCluster.ID === cluster.ID, `Custom cluster ID (${cluster.ID}) should match existing cluster ID (${existingCluster.ID})`);
            const extendedCluster = {
                name: cluster.name,
                ID: cluster.ID,
                manufacturerCode: cluster.manufacturerCode,
                attributes: { ...existingCluster.attributes, ...cluster.attributes },
                commands: { ...existingCluster.commands, ...cluster.commands },
                commandsResponse: { ...existingCluster.commandsResponse, ...cluster.commandsResponse },
            };
            this._customClusters[name] = extendedCluster;
        }
        else {
            this._customClusters[name] = cluster;
        }
    }
    #waitForOtaCommand(endpointId, commandId, transactionSequenceNumber, timeout) {
        const waiter = entity_1.default.adapter.waitFor(this.networkAddress, endpointId, Zcl.FrameType.SPECIFIC, Zcl.Direction.CLIENT_TO_SERVER, transactionSequenceNumber, GEN_OTA_CLUSTER_ID, commandId, timeout);
        const promise = new Promise((resolve, reject) => {
            waiter.promise.then((payload) => {
                try {
                    const frame = Zcl.Frame.fromBuffer(payload.clusterID, payload.header, payload.data, this.customClusters);
                    resolve(frame);
                }
                catch (error) {
                    reject(error);
                }
            }, (error) => reject(error));
        });
        return { promise, cancel: waiter.cancel };
    }
    async findMatchingOtaImage(source, current, extraMetas) {
        logger_1.logger.debug(() => `Getting image metadata for ${this.ieeeAddr}...`, NS);
        const images = await (0, ota_1.getOtaIndex)(source);
        // NOTE: Officially an image can be determined with a combination of manufacturerCode and imageType.
        // However several manufacturers do not follow the spec properly.
        // The index provides the needed extra metadata to prevent mismatches.
        // e.g. Tuya must match on manufacturerName, Gledopto on modelId...
        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 this.modelID, same for manufacturerName
            (!i.modelId || i.modelId === this.modelID || i.modelId === extraMetas.modelId) &&
            (!i.manufacturerName ||
                // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
                i.manufacturerName.includes(this.manufacturerName) ||
                // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
                i.manufacturerName.includes(extraMetas.manufacturerName)) &&
            (!extraMetas.otaHeaderString || i.otaHeaderString === extraMetas.otaHeaderString) &&
            (i.hardwareVersionMin === undefined ||
                (current.hardwareVersion !== undefined && current.hardwareVersion >= i.hardwareVersionMin) ||
                (extraMetas.hardwareVersionMin !== undefined && extraMetas.hardwareVersionMin >= i.hardwareVersionMin)) &&
            (i.hardwareVersionMax === undefined ||
                (current.hardwareVersion !== undefined && current.hardwareVersion <= i.hardwareVersionMax) ||
                (extraMetas.hardwareVersionMax !== undefined && extraMetas.hardwareVersionMax <= i.hardwareVersionMax)));
    }
    async #notifyOta(endpoint) {
        // Some devices (e.g. Insta) take a very long trying to discover the correct coordinator EP for OTA
        const queryNextImageRequest = this.#waitForOtaCommand(endpoint.ID, Zcl.Clusters.genOta.commands.queryNextImageRequest.ID, undefined, 60000);
        try {
            await endpoint.commandResponse("genOta", "imageNotify", { payloadType: 0, queryJitter: 100 }, { sendPolicy: "immediate" });
            const response = await queryNextImageRequest.promise;
            return [response.payload, response.header.transactionSequenceNumber];
        }
        catch {
            queryNextImageRequest.cancel();
            throw new Error(`Device didn't respond to OTA request`);
        }
    }
    /**
     * If `current` is undefined, will automatically notify and reply to query with `NO_IMAGE_AVAILABLE` (stops device from doing further requests).
     */
    async checkOta(source, current, extraMetas, endpoint = this.endpoints.find((e) => e.supportsOutputCluster("genOta"))) {
        (0, node_assert_1.default)(endpoint !== undefined, `No endpoint found with OTA cluster support for ${this.ieeeAddr}`);
        if (this.modelID === "PP-WHT-US") {
            // see https://github.com/Koenkk/zigbee-OTA/pull/14
            const scenesEndpoint = this.endpoints.find((e) => e.supportsOutputCluster("genScenes"));
            if (scenesEndpoint !== undefined) {
                await scenesEndpoint.write("genScenes", { currentGroup: 49502 });
            }
        }
        if (current === undefined) {
            let queryTsn;
            [current, queryTsn] = await this.#notifyOta(endpoint);
            await endpoint.commandResponse("genOta", "queryNextImageResponse", { status: Zcl.Status.NO_IMAGE_AVAILABLE }, undefined, queryTsn);
        }
        logger_1.logger.debug(() => `Checking OTA ${this.ieeeAddr} ${source.downgrade ? "downgrade" : "upgrade"} image availability, current=${JSON.stringify(current)}`, NS);
        if (this.meta.lumiFileVersion &&
            (this.modelID === "lumi.airrtc.agl001" || this.modelID === "lumi.curtain.acn003" || this.modelID === "lumi.curtain.agl001")) {
            // 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
            current = { ...current, fileVersion: this.meta.lumiFileVersion };
        }
        const meta = await this.findMatchingOtaImage(source, current, extraMetas);
        if (!meta) {
            // no image in repo/URL for specified device
            return {
                available: false,
                current,
            };
        }
        logger_1.logger.debug(() => `OTA ${source.downgrade ? "downgrade" : "upgrade"} image availability for ${this.ieeeAddr}, available=${JSON.stringify(meta)}`, NS);
        return {
            available: meta.force ? true : source.downgrade ? current.fileVersion > meta.fileVersion : current.fileVersion < meta.fileVersion,
            current,
            availableMeta: meta,
        };
    }
    async updateOta(source, requestPayload, requestTsn, extraMetas, onProgress, dataSettings, endpoint = this.endpoints.find((e) => e.supportsOutputCluster("genOta"))) {
        (0, node_assert_1.default)(this.#otaInProgress === false, `OTA already in progress for ${this.ieeeAddr}`);
        (0, node_assert_1.default)(endpoint !== undefined, `No endpoint found with OTA cluster support for ${this.ieeeAddr}`);
        if (source === undefined) {
            (0, node_assert_1.default)(this.#scheduledOta !== undefined, `No currently scheduled OTA for ${this.ieeeAddr}`);
            source = this.#scheduledOta;
        }
        this.#otaInProgress = true;
        // always expected both undefined if one is, but just in case
        if (requestPayload === undefined || requestTsn === undefined) {
            try {
                [requestPayload, requestTsn] = await this.#notifyOta(endpoint);
            }
            finally {
                this.#otaInProgress = false;
            }
        }
        let available = false;
        let image;
        if (source.url && !source.url.endsWith(".json")) {
            // firmware file at `source.url`
            try {
                const downloadedFile = await (0, ota_1.getOtaFirmware)(source.url, undefined);
                image = (0, ota_1.parseOtaImage)(downloadedFile);
                available = source.downgrade
                    ? requestPayload.fileVersion > image.header.fileVersion
                    : requestPayload.fileVersion < image.header.fileVersion;
                logger_1.logger.debug(() => 
                // biome-ignore lint/style/noNonNullAssertion: valid from above, won't change after assignment
                `Parsed image from '${source.url}' for ${this.ieeeAddr}, header=${JSON.stringify(image.header)}`, NS);
            }
            catch (error) {
                logger_1.logger.error(`Failed to parse OTA image from '${source.url}' for ${this.ieeeAddr}, aborting (${error.message})`, NS);
                // biome-ignore lint/style/noNonNullAssertion: expected valid
                logger_1.logger.debug(error.stack, NS);
            }
        }
        else {
            let availableMeta;
            try {
                // index file at `source.url` (or undefined to use defaults)
                ({ available, availableMeta } = await this.checkOta(source, requestPayload, extraMetas, endpoint));
            }
            finally {
                this.#otaInProgress = false;
            }
            if (available && availableMeta) {
                try {
                    const downloadedFile = await (0, ota_1.getOtaFirmware)(availableMeta.url, availableMeta.sha512);
                    image = (0, ota_1.parseOtaImage)(downloadedFile);
                    logger_1.logger.debug(() => 
                    // biome-ignore lint/style/noNonNullAssertion: valid from above, won't change after assignment
                    `Parsed image from '${availableMeta.url}' for ${this.ieeeAddr}, header=${JSON.stringify(image.header)}`, NS);
                }
                catch (error) {
                    logger_1.logger.error(`Failed to parse OTA image for ${this.ieeeAddr}, aborting (${error.message})`, NS);
                    // biome-ignore lint/style/noNonNullAssertion: expected valid
                    logger_1.logger.debug(error.stack, NS);
                }
            }
            else {
                logger_1.logger.info(() => `No OTA ${source.downgrade ? "downgrade" : "upgrade"} image currently available for ${this.ieeeAddr}`, NS);
            }
        }
        // reply to `queryNextImageRequest` now that we have the data for it, should trigger image block/page request from device
        // NOTE: previous code had try/catch wrapping with ignored error, but that doesn't look good (would fail to start OTA from device side)
        try {
            await endpoint.commandResponse("genOta", "queryNextImageResponse", image && available
                ? {
                    status: Zcl.Status.SUCCESS,
                    manufacturerCode: image.header.manufacturerCode,
                    imageType: image.header.imageType,
                    fileVersion: image.header.fileVersion,
                    imageSize: image.header.totalImageSize,
                }
                : { status: Zcl.Status.NO_IMAGE_AVAILABLE }, undefined, requestTsn);
        }
        finally {
            this.#otaInProgress = false;
        }
        if (!image || !available) {
            this.#otaInProgress = false;
            return [requestPayload, undefined];
        }
        logger_1.logger.debug(() => `Starting OTA update for ${this.ieeeAddr}`, NS);
        const session = new ota_1.OtaSession(this.ieeeAddr, endpoint, image, onProgress, dataSettings, this.#waitForOtaCommand.bind(this));
        let endResult;
        try {
            endResult = await session.run();
        }
        finally {
            this.#otaInProgress = false;
        }
        logger_1.logger.debug(() => `Received upgrade end request for ${this.ieeeAddr}: ${JSON.stringify(endResult.payload)}`, NS);
        if (endResult.payload.status === Zcl.Status.SUCCESS) {
            try {
                const currentTime = timeService.timestampToZigbeeUtcTime(Date.now());
                await endpoint.commandResponse("genOta", "upgradeEndResponse", {
                    manufacturerCode: image.header.manufacturerCode,
                    imageType: image.header.imageType,
                    fileVersion: image.header.fileVersion,
                    currentTime,
                    upgradeTime: currentTime + 1, // TODO: could this tiny offset be a problem for some stacks?
                }, undefined, endResult.header.transactionSequenceNumber);
                onProgress(100, 0);
                logger_1.logger.info(() => `Update of ${this.ieeeAddr} successful (${Math.round((performance.now() - session.startTime) / 1000)} seconds). Waiting for device announce...`, NS);
                let timer;
                await new Promise((resolve) => {
                    const onDeviceAnnounce = () => {
                        clearTimeout(timer);
                        logger_1.logger.debug(() => `Received device announce for ${this.ieeeAddr}, OTA update finished.`, NS);
                        resolve();
                    };
                    // force "finished" after given time
                    timer = setTimeout(() => {
                        this.removeListener("deviceAnnounce", onDeviceAnnounce);
                        logger_1.logger.debug(() => `Timed out waiting for device announce for ${this.ieeeAddr}, OTA update considered finished.`, NS);
                        resolve();
                    }, 120000 /** consider "done" after timeout even if no announce seen */);
                    this.once("deviceAnnounce", onDeviceAnnounce);
                });
                // only "cancel" possible scheduled OTA when successful
                this.#scheduledOta = undefined;
                this.#otaInProgress = false;
                return [
                    requestPayload,
                    {
                        fieldControl: 0,
                        manufacturerCode: image.header.manufacturerCode,
                        imageType: image.header.imageType,
                        fileVersion: image.header.fileVersion,
                    },
                ];
            }
            catch (error) {
                this.#otaInProgress = false;
                throw new Error(`OTA 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(Zcl.Clusters.genOta.commands.upgradeEndRequest.ID, Zcl.Status.SUCCESS, GEN_OTA_CLUSTER_ID, endResult.header.transactionSequenceNumber);
            }
            catch (error) {
                logger_1.logger.debug(() => `OTA upgrade end request default response for ${this.ieeeAddr} failed: ${error.message}`, NS);
            }
            this.#otaInProgress = false;
            throw new Error(`OTA update of ${this.ieeeAddr} failed with reason: ${Zcl.Status[endResult.payload.status]}`);
        }
    }
    scheduleOta(source) {
        (0, node_assert_1.default)(this.endpoints.some((e) => e.supportsOutputCluster("genOta")), `No endpoint found with OTA cluster support for ${this.ieeeAddr}`);
        if (this.#scheduledOta) {
            logger_1.logger.info(`Previously scheduled OTA update for '${this.ieeeAddr}' was cancelled in favor of new schedule request`, NS);
        }
        this.#scheduledOta = source;
        logger_1.logger.info(`Scheduled OTA update for '${this.ieeeAddr}' on next request from device`, NS);
    }
    unscheduleOta() {
        if (this.#scheduledOta !== undefined) {
            this.#scheduledOta = undefined;
            logger_1.logger.info(`Previously scheduled OTA update for '${this.ieeeAddr}' was cancelled`, NS);
        }
    }
}
exports.Device = Device;
exports.default = Device;
//# sourceMappingURL=device.js.map