"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.Controller = void 0;
const node_assert_1 = __importDefault(require("node:assert"));
const node_events_1 = __importDefault(require("node:events"));
const node_fs_1 = __importDefault(require("node:fs"));
const mixin_deep_1 = __importDefault(require("mixin-deep"));
const adapter_1 = require("../adapter");
const utils_1 = require("../utils");
const logger_1 = require("../utils/logger");
const utils_2 = require("../utils/utils");
const ZSpec = __importStar(require("../zspec"));
const Zcl = __importStar(require("../zspec/zcl"));
const Zdo = __importStar(require("../zspec/zdo"));
const database_1 = __importDefault(require("./database"));
const greenPower_1 = __importDefault(require("./greenPower"));
const helpers_1 = require("./helpers");
const model_1 = require("./model");
const group_1 = __importDefault(require("./model/group"));
const touchlink_1 = __importDefault(require("./touchlink"));
const NS = 'zh:controller';
const DefaultOptions = {
    network: {
        networkKeyDistribute: false,
        networkKey: [0x01, 0x03, 0x05, 0x07, 0x09, 0x0b, 0x0d, 0x0f, 0x00, 0x02, 0x04, 0x06, 0x08, 0x0a, 0x0c, 0x0d],
        panID: 0x1a62,
        extendedPanID: [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd],
        channelList: [11],
    },
    serialPort: {},
    adapter: { disableLED: false },
};
/**
 * @noInheritDoc
 */
class Controller extends node_events_1.default.EventEmitter {
    options;
    // @ts-expect-error assigned and validated in start()
    database;
    // @ts-expect-error assigned and validated in start()
    adapter;
    // @ts-expect-error assigned and validated in start()
    greenPower;
    // @ts-expect-error assigned and validated in start()
    touchlink;
    permitJoinTimer;
    permitJoinEnd;
    backupTimer;
    databaseSaveTimer;
    stopping;
    adapterDisconnected;
    networkParametersCached;
    /** List of unknown devices detected during a single runtime session. Serves as de-dupe and anti-spam. */
    unknownDevices;
    /**
     * Create a controller
     *
     * To auto detect the port provide `null` for `options.serialPort.path`
     */
    constructor(options) {
        super();
        this.stopping = false;
        this.adapterDisconnected = true; // set false after adapter.start() is successfully called
        this.options = (0, mixin_deep_1.default)(JSON.parse(JSON.stringify(DefaultOptions)), options);
        this.unknownDevices = new Set();
        // Validate options
        for (const channel of this.options.network.channelList) {
            if (channel < 11 || channel > 26) {
                throw new Error(`'${channel}' is an invalid channel, use a channel between 11 - 26.`);
            }
        }
        if (!(0, utils_2.isNumberArrayOfLength)(this.options.network.networkKey, 16)) {
            throw new Error(`Network key must be a 16 digits long array, got ${this.options.network.networkKey}.`);
        }
        if (!(0, utils_2.isNumberArrayOfLength)(this.options.network.extendedPanID, 8)) {
            throw new Error(`ExtendedPanID must be an 8 digits long array, got ${this.options.network.extendedPanID}.`);
        }
        if (this.options.network.panID < 1 || this.options.network.panID >= 0xffff) {
            throw new Error(`PanID must have a value of 0x0001 (1) - 0xFFFE (65534), got ${this.options.network.panID}.`);
        }
    }
    /**
     * Start the Herdsman controller
     */
    async start() {
        // Database (create end inject)
        this.database = database_1.default.open(this.options.databasePath);
        model_1.Entity.injectDatabase(this.database);
        // Adapter (create and inject)
        this.adapter = await adapter_1.Adapter.create(this.options.network, this.options.serialPort, this.options.backupPath, this.options.adapter);
        const stringifiedOptions = JSON.stringify(this.options).replaceAll(JSON.stringify(this.options.network.networkKey), '"HIDDEN"');
        logger_1.logger.debug(`Starting with options '${stringifiedOptions}'`, NS);
        const startResult = await this.adapter.start();
        logger_1.logger.debug(`Started with result '${startResult}'`, NS);
        this.adapterDisconnected = false;
        // Check if we have to change the channel, only do this when adapter `resumed` because:
        // - `getNetworkParameters` might be return wrong info because it needs to propogate after backup restore
        // - If result is not `resumed` (`reset` or `restored`), the adapter should comission with the channel from `this.options.network`
        if (startResult === 'resumed') {
            const netParams = await this.getNetworkParameters();
            const configuredChannel = this.options.network.channelList[0];
            const adapterChannel = netParams.channel;
            const nwkUpdateID = netParams.nwkUpdateID;
            if (configuredChannel != adapterChannel) {
                logger_1.logger.info(`Configured channel '${configuredChannel}' does not match adapter channel '${adapterChannel}', changing channel`, NS);
                await this.changeChannel(adapterChannel, configuredChannel, nwkUpdateID);
            }
        }
        model_1.Entity.injectAdapter(this.adapter);
        // log injection
        logger_1.logger.debug(`Injected database: ${this.database != undefined}, adapter: ${this.adapter != undefined}`, NS);
        this.greenPower = new greenPower_1.default(this.adapter);
        this.greenPower.on('deviceJoined', this.onDeviceJoinedGreenPower.bind(this));
        this.greenPower.on('deviceLeave', this.onDeviceLeaveGreenPower.bind(this));
        // Register adapter events
        this.adapter.on('deviceJoined', this.onDeviceJoined.bind(this));
        this.adapter.on('zclPayload', this.onZclPayload.bind(this));
        this.adapter.on('zdoResponse', this.onZdoResponse.bind(this));
        this.adapter.on('disconnected', this.onAdapterDisconnected.bind(this));
        this.adapter.on('deviceLeave', this.onDeviceLeave.bind(this));
        if (startResult === 'reset') {
            if (this.options.databaseBackupPath && node_fs_1.default.existsSync(this.options.databasePath)) {
                node_fs_1.default.copyFileSync(this.options.databasePath, this.options.databaseBackupPath);
            }
            logger_1.logger.debug('Clearing database...', NS);
            for (const group of group_1.default.allIterator()) {
                group.removeFromDatabase();
            }
            for (const device of model_1.Device.allIterator()) {
                device.removeFromDatabase();
            }
            model_1.Device.resetCache();
        }
        if (startResult === 'reset' || (this.options.backupPath && !node_fs_1.default.existsSync(this.options.backupPath))) {
            await this.backup();
        }
        // Add coordinator to the database if it is not there yet.
        const coordinatorIEEE = await this.adapter.getCoordinatorIEEE();
        if (model_1.Device.byType('Coordinator').length === 0) {
            logger_1.logger.debug('No coordinator in database, querying...', NS);
            const coordinator = model_1.Device.create('Coordinator', coordinatorIEEE, ZSpec.COORDINATOR_ADDRESS, this.adapter.manufacturerID, undefined, undefined, undefined, true, undefined);
            await coordinator.updateActiveEndpoints();
            for (const endpoint of coordinator.endpoints) {
                await endpoint.updateSimpleDescriptor();
            }
            coordinator.save();
        }
        // Update coordinator ieeeAddr if changed, can happen due to e.g. reflashing
        const databaseCoordinator = model_1.Device.byType('Coordinator')[0];
        if (databaseCoordinator.ieeeAddr !== coordinatorIEEE) {
            logger_1.logger.info(`Coordinator address changed, updating to '${coordinatorIEEE}'`, NS);
            databaseCoordinator.changeIeeeAddress(coordinatorIEEE);
        }
        // Set backup timer to 1 day.
        this.backupTimer = setInterval(() => this.backup(), 86400000);
        // Set database save timer to 1 hour.
        this.databaseSaveTimer = setInterval(() => this.databaseSave(), 3600000);
        this.touchlink = new touchlink_1.default(this.adapter);
        return startResult;
    }
    async touchlinkIdentify(ieeeAddr, channel) {
        await this.touchlink.identify(ieeeAddr, channel);
    }
    async touchlinkScan() {
        return await this.touchlink.scan();
    }
    async touchlinkFactoryReset(ieeeAddr, channel) {
        return await this.touchlink.factoryReset(ieeeAddr, channel);
    }
    async touchlinkFactoryResetFirst() {
        return await this.touchlink.factoryResetFirst();
    }
    async addInstallCode(installCode) {
        const aqaraMatch = installCode.match(/^G\$M:.+\$A:(.+)\$I:(.+)$/);
        const pipeMatch = installCode.match(/^(.+)\|(.+)$/);
        let ieeeAddr, key;
        if (aqaraMatch) {
            ieeeAddr = aqaraMatch[1];
            key = aqaraMatch[2];
        }
        else if (pipeMatch) {
            ieeeAddr = pipeMatch[1];
            key = pipeMatch[2];
        }
        else {
            (0, node_assert_1.default)(installCode.length === 95 || installCode.length === 91, `Unsupported install code, got ${installCode.length} chars, expected 95 or 91`);
            const keyStart = installCode.length - (installCode.length === 95 ? 36 : 32);
            ieeeAddr = installCode.substring(keyStart - 19, keyStart - 3);
            key = installCode.substring(keyStart, installCode.length);
        }
        ieeeAddr = `0x${ieeeAddr}`;
        // match valid else asserted above
        key = Buffer.from(key.match(/.{1,2}/g).map((d) => parseInt(d, 16)));
        // will throw if code cannot be fixed and is invalid
        const [adjustedKey, adjusted] = ZSpec.Utils.checkInstallCode(key, true);
        if (adjusted) {
            logger_1.logger.info(`Install code was adjusted for reason '${adjusted}'.`, NS);
        }
        logger_1.logger.info(`Adding install code for ${ieeeAddr}.`, NS);
        await this.adapter.addInstallCode(ieeeAddr, adjustedKey, false);
        if (adjusted === 'missing CRC') {
            // in case the CRC was missing, could also be a "already-hashed" key, send both
            // XXX: seems to be the case for old HA1.2 devices
            await this.adapter.addInstallCode(ieeeAddr, key, true);
        }
    }
    async permitJoin(time, device) {
        clearTimeout(this.permitJoinTimer);
        this.permitJoinTimer = undefined;
        this.permitJoinEnd = undefined;
        if (time > 0) {
            // never permit more than uint8, and never permit 255 that is often equal to "forever"
            (0, node_assert_1.default)(time <= 254, `Cannot permit join for more than 254 seconds.`);
            await this.adapter.permitJoin(time, device?.networkAddress);
            await this.greenPower.permitJoin(time, device?.networkAddress);
            const timeMs = time * 1000;
            this.permitJoinEnd = Date.now() + timeMs;
            this.permitJoinTimer = setTimeout(() => {
                this.emit('permitJoinChanged', { permitted: false });
                this.permitJoinTimer = undefined;
                this.permitJoinEnd = undefined;
            }, timeMs);
            this.emit('permitJoinChanged', { permitted: true, time });
        }
        else {
            logger_1.logger.debug('Disable joining', NS);
            await this.greenPower.permitJoin(0);
            await this.adapter.permitJoin(0);
            this.emit('permitJoinChanged', { permitted: false });
        }
    }
    getPermitJoin() {
        return this.permitJoinTimer !== undefined;
    }
    getPermitJoinEnd() {
        return this.permitJoinEnd;
    }
    isStopping() {
        return this.stopping;
    }
    isAdapterDisconnected() {
        return this.adapterDisconnected;
    }
    async stop() {
        this.stopping = true;
        // Unregister adapter events
        this.adapter.removeAllListeners();
        clearInterval(this.backupTimer);
        clearInterval(this.databaseSaveTimer);
        if (this.adapterDisconnected) {
            this.databaseSave();
        }
        else {
            try {
                await this.permitJoin(0);
            }
            catch (error) {
                logger_1.logger.error(`Failed to disable join on stop: ${error}`, NS);
            }
            await this.backup(); // always calls databaseSave()
            await this.adapter.stop();
            this.adapterDisconnected = true;
        }
        model_1.Device.resetCache();
        group_1.default.resetCache();
    }
    databaseSave() {
        for (const device of model_1.Device.allIterator()) {
            device.save(false);
        }
        for (const group of group_1.default.allIterator()) {
            group.save(false);
        }
        this.database.write();
    }
    async backup() {
        this.databaseSave();
        if (this.options.backupPath && (await this.adapter.supportsBackup())) {
            logger_1.logger.debug('Creating coordinator backup', NS);
            const backup = await this.adapter.backup(this.getDeviceIeeeAddresses());
            const unifiedBackup = await utils_1.BackupUtils.toUnifiedBackup(backup);
            const tmpBackupPath = this.options.backupPath + '.tmp';
            node_fs_1.default.writeFileSync(tmpBackupPath, JSON.stringify(unifiedBackup, null, 2));
            node_fs_1.default.renameSync(tmpBackupPath, this.options.backupPath);
            logger_1.logger.info(`Wrote coordinator backup to '${this.options.backupPath}'`, NS);
        }
    }
    async coordinatorCheck() {
        if (await this.adapter.supportsBackup()) {
            const backup = await this.adapter.backup(this.getDeviceIeeeAddresses());
            const devicesInBackup = backup.devices.map((d) => ZSpec.Utils.eui64BEBufferToHex(d.ieeeAddress));
            const missingRouters = [];
            for (const device of this.getDevicesIterator((d) => d.type === 'Router' && !devicesInBackup.includes(d.ieeeAddr))) {
                missingRouters.push(device);
            }
            return { missingRouters };
        }
        else {
            throw new Error("Coordinator does not coordinator check because it doesn't support backups");
        }
    }
    async reset(type) {
        await this.adapter.reset(type);
    }
    async getCoordinatorVersion() {
        return await this.adapter.getCoordinatorVersion();
    }
    async getNetworkParameters() {
        // Cache network parameters as they don't change anymore after start.
        if (!this.networkParametersCached) {
            this.networkParametersCached = await this.adapter.getNetworkParameters();
        }
        return this.networkParametersCached;
    }
    /**
     * Get all devices
     * @deprecated use getDevicesIterator()
     */
    getDevices() {
        return model_1.Device.all();
    }
    /**
     * Get iterator for all devices
     */
    getDevicesIterator(predicate) {
        return model_1.Device.allIterator(predicate);
    }
    /**
     * Get all devices with a specific type
     */
    getDevicesByType(type) {
        return model_1.Device.byType(type);
    }
    /**
     * Get device by ieeeAddr
     */
    getDeviceByIeeeAddr(ieeeAddr) {
        return model_1.Device.byIeeeAddr(ieeeAddr);
    }
    /**
     * Get device by networkAddress
     */
    getDeviceByNetworkAddress(networkAddress) {
        return model_1.Device.byNetworkAddress(networkAddress);
    }
    /**
     * Get IEEE address for all devices
     */
    getDeviceIeeeAddresses() {
        const deviceIeeeAddresses = [];
        for (const device of model_1.Device.allIterator()) {
            deviceIeeeAddresses.push(device.ieeeAddr);
        }
        return deviceIeeeAddresses;
    }
    /**
     * Get group by ID
     */
    getGroupByID(groupID) {
        return group_1.default.byGroupID(groupID);
    }
    /**
     * Get all groups
     * @deprecated use getGroupsIterator()
     */
    getGroups() {
        return group_1.default.all();
    }
    /**
     * Get iterator for all groups
     */
    getGroupsIterator(predicate) {
        return group_1.default.allIterator(predicate);
    }
    /**
     * Create a Group
     */
    createGroup(groupID) {
        return group_1.default.create(groupID);
    }
    /**
     * Broadcast a network-wide channel change.
     */
    async changeChannel(oldChannel, newChannel, nwkUpdateID) {
        logger_1.logger.warning(`Changing channel from '${oldChannel}' to '${newChannel}'`, NS);
        // According to the Zigbee specification:
        // When broadcasting a Mgmt_NWK_Update_req to notify devices of a new channel, the nwkUpdateId parameter should be incremented in the NIB and included in the Mgmt_NWK_Update_req.
        // The valid range of nwkUpdateId is 0x00 to 0xFF, and it should wrap back to 0 if necessary.
        if (++nwkUpdateID > 0xff) {
            nwkUpdateID = 0x00;
        }
        const clusterId = Zdo.ClusterId.NWK_UPDATE_REQUEST;
        const zdoPayload = Zdo.Buffalo.buildRequest(this.adapter.hasZdoMessageOverhead, clusterId, [newChannel], 0xfe, undefined, nwkUpdateID, undefined);
        await this.adapter.sendZdo(ZSpec.BLANK_EUI64, ZSpec.BroadcastAddress.SLEEPY, clusterId, zdoPayload, true);
        logger_1.logger.info(`Channel changed to '${newChannel}'`, NS);
        this.networkParametersCached = undefined; // invalidate cache
        // wait for the broadcast to propagate and the adapter to actually change
        // NOTE: observed to ~9sec on `ember` with actual stack event
        await (0, utils_1.wait)(12000);
    }
    async identifyUnknownDevice(nwkAddress) {
        if (this.unknownDevices.has(nwkAddress)) {
            // prevent duplicate triggering
            return;
        }
        logger_1.logger.debug(`Trying to identify unknown device with address '${nwkAddress}'`, NS);
        this.unknownDevices.add(nwkAddress);
        const clusterId = Zdo.ClusterId.IEEE_ADDRESS_REQUEST;
        const zdoPayload = Zdo.Buffalo.buildRequest(this.adapter.hasZdoMessageOverhead, clusterId, nwkAddress, false, 0);
        try {
            const response = await this.adapter.sendZdo(ZSpec.BLANK_EUI64, nwkAddress, clusterId, zdoPayload, false);
            if (Zdo.Buffalo.checkStatus(response)) {
                const payload = response[1];
                const device = model_1.Device.byIeeeAddr(payload.eui64);
                if (device) {
                    this.checkDeviceNetworkAddress(device, payload.eui64, payload.nwkAddress);
                    this.unknownDevices.delete(payload.nwkAddress);
                }
                return device;
            }
            else {
                throw new Zdo.StatusError(response[0]);
            }
        }
        catch (error) {
            // Catches 2 types of exception: Zdo.StatusError and no response from `adapter.sendZdo()`.
            logger_1.logger.debug(`Failed to retrieve IEEE address for device '${nwkAddress}': ${error}`, NS);
        }
        // NOTE: by keeping nwkAddress in `this.unknownDevices` on fail, it prevents a non-responding device from potentially spamming identify.
        // This only lasts until next reboot (runtime Set), allowing to 'force' another trigger if necessary.
    }
    checkDeviceNetworkAddress(device, ieeeAddress, nwkAddress) {
        if (device.networkAddress !== nwkAddress) {
            logger_1.logger.debug(`Device '${ieeeAddress}' got new networkAddress '${nwkAddress}'`, NS);
            device.networkAddress = nwkAddress;
            device.save();
            this.selfAndDeviceEmit(device, 'deviceNetworkAddressChanged', { device });
        }
    }
    onNetworkAddress(payload) {
        logger_1.logger.debug(`Network address from '${payload.eui64}:${payload.nwkAddress}'`, NS);
        const device = model_1.Device.byIeeeAddr(payload.eui64);
        if (!device) {
            logger_1.logger.debug(`Network address is from unknown device '${payload.eui64}:${payload.nwkAddress}'`, NS);
            return;
        }
        device.updateLastSeen();
        this.selfAndDeviceEmit(device, 'lastSeenChanged', { device, reason: 'networkAddress' });
        this.checkDeviceNetworkAddress(device, payload.eui64, payload.nwkAddress);
    }
    onIEEEAddress(payload) {
        logger_1.logger.debug(`IEEE address from '${payload.eui64}:${payload.nwkAddress}'`, NS);
        const device = model_1.Device.byIeeeAddr(payload.eui64);
        if (!device) {
            logger_1.logger.debug(`IEEE address is from unknown device '${payload.eui64}:${payload.nwkAddress}'`, NS);
            return;
        }
        device.updateLastSeen();
        this.selfAndDeviceEmit(device, 'lastSeenChanged', { device, reason: 'networkAddress' });
        this.checkDeviceNetworkAddress(device, payload.eui64, payload.nwkAddress);
    }
    onDeviceAnnounce(payload) {
        logger_1.logger.debug(`Device announce from '${payload.eui64}:${payload.nwkAddress}'`, NS);
        const device = model_1.Device.byIeeeAddr(payload.eui64);
        if (!device) {
            logger_1.logger.debug(`Device announce is from unknown device '${payload.eui64}:${payload.nwkAddress}'`, NS);
            return;
        }
        device.updateLastSeen();
        this.selfAndDeviceEmit(device, 'lastSeenChanged', { device, reason: 'deviceAnnounce' });
        device.implicitCheckin();
        this.checkDeviceNetworkAddress(device, payload.eui64, payload.nwkAddress);
        this.selfAndDeviceEmit(device, 'deviceAnnounce', { device });
    }
    onDeviceLeave(payload) {
        logger_1.logger.debug(`Device leave '${payload.ieeeAddr}'`, NS);
        // XXX: seems type is not properly detected?
        const device = payload.ieeeAddr ? model_1.Device.byIeeeAddr(payload.ieeeAddr) : model_1.Device.byNetworkAddress(payload.networkAddress);
        if (!device) {
            logger_1.logger.debug(`Device leave is from unknown or already deleted device '${payload.ieeeAddr ?? payload.networkAddress}'`, NS);
            return;
        }
        logger_1.logger.debug(`Removing device from database '${device.ieeeAddr}'`, NS);
        device.removeFromDatabase();
        this.selfAndDeviceEmit(device, 'deviceLeave', { ieeeAddr: device.ieeeAddr });
    }
    async onAdapterDisconnected() {
        logger_1.logger.debug(`Adapter disconnected`, NS);
        this.adapterDisconnected = true;
        try {
            await this.adapter.stop();
        }
        catch (error) {
            logger_1.logger.error(`Failed to stop adapter on disconnect: ${error}`, NS);
        }
        this.emit('adapterDisconnected');
    }
    async onDeviceJoinedGreenPower(payload) {
        logger_1.logger.debug(() => `Green power device '${JSON.stringify(payload).replaceAll(/\[[\d,]+\]/g, 'HIDDEN')}' joined`, NS);
        // Green power devices don't have an ieeeAddr, the sourceID is unique and static so use this.
        const ieeeAddr = `0x${payload.sourceID.toString(16).padStart(16, '0')}`;
        // Green power devices dont' have a modelID, create a modelID based on the deviceID (=type)
        const modelID = `GreenPower_${payload.deviceID}`;
        let device = model_1.Device.byIeeeAddr(ieeeAddr, true);
        if (!device) {
            logger_1.logger.debug(`New green power device '${ieeeAddr}' joined`, NS);
            logger_1.logger.debug(`Creating device '${ieeeAddr}'`, NS);
            device = model_1.Device.create('GreenPower', ieeeAddr, payload.networkAddress, undefined, undefined, undefined, modelID, true, payload.securityKey ? Array.from(payload.securityKey) : /* v8 ignore next */ undefined);
            device.save();
            this.selfAndDeviceEmit(device, 'deviceJoined', { device });
            this.selfAndDeviceEmit(device, 'deviceInterview', { status: 'successful', device });
        }
        else if (device.isDeleted) {
            logger_1.logger.debug(`Deleted green power device '${ieeeAddr}' joined, undeleting`, NS);
            device.undelete(true);
            this.selfAndDeviceEmit(device, 'deviceJoined', { device });
            this.selfAndDeviceEmit(device, 'deviceInterview', { status: 'successful', device });
        }
    }
    async onDeviceLeaveGreenPower(sourceID) {
        logger_1.logger.debug(`Green power device '${sourceID}' left`, NS);
        // Green power devices don't have an ieeeAddr, the sourceID is unique and static so use this.
        const ieeeAddr = `0x${sourceID.toString(16).padStart(16, '0')}`;
        const device = model_1.Device.byIeeeAddr(ieeeAddr);
        if (!device) {
            logger_1.logger.debug(`Green power device leave is from unknown or already deleted device '${ieeeAddr}'`, NS);
            return;
        }
        logger_1.logger.debug(`Removing green power device from database '${device.ieeeAddr}'`, NS);
        device.removeFromDatabase();
        this.selfAndDeviceEmit(device, 'deviceLeave', { ieeeAddr: device.ieeeAddr });
    }
    selfAndDeviceEmit(device, event, ...args) {
        device.emit(event, ...args);
        this.emit(event, ...args);
    }
    async onDeviceJoined(payload) {
        logger_1.logger.debug(`Device '${payload.ieeeAddr}' joined`, NS);
        if (this.options.acceptJoiningDeviceHandler) {
            if (!(await this.options.acceptJoiningDeviceHandler(payload.ieeeAddr))) {
                logger_1.logger.debug(`Device '${payload.ieeeAddr}' rejected by handler, removing it`, NS);
                // XXX: GP devices? see Device.removeFromNetwork
                try {
                    const clusterId = Zdo.ClusterId.LEAVE_REQUEST;
                    const zdoPayload = Zdo.Buffalo.buildRequest(this.adapter.hasZdoMessageOverhead, clusterId, payload.ieeeAddr, Zdo.LeaveRequestFlags.WITHOUT_REJOIN);
                    const response = await this.adapter.sendZdo(payload.ieeeAddr, payload.networkAddress, clusterId, zdoPayload, false);
                    if (!Zdo.Buffalo.checkStatus(response)) {
                        throw new Zdo.StatusError(response[0]);
                    }
                }
                catch (error) {
                    logger_1.logger.error(`Failed to remove rejected device: ${error.message}`, NS);
                }
                return;
            }
            else {
                logger_1.logger.debug(`Device '${payload.ieeeAddr}' accepted by handler`, NS);
            }
        }
        let device = model_1.Device.byIeeeAddr(payload.ieeeAddr, true);
        if (!device) {
            logger_1.logger.debug(`New device '${payload.ieeeAddr}' joined`, NS);
            logger_1.logger.debug(`Creating device '${payload.ieeeAddr}'`, NS);
            device = model_1.Device.create('Unknown', payload.ieeeAddr, payload.networkAddress, undefined, undefined, undefined, undefined, false, undefined);
            this.selfAndDeviceEmit(device, 'deviceJoined', { device });
        }
        else if (device.isDeleted) {
            logger_1.logger.debug(`Deleted device '${payload.ieeeAddr}' joined, undeleting`, NS);
            device.undelete();
            this.selfAndDeviceEmit(device, 'deviceJoined', { device });
        }
        if (device.networkAddress !== payload.networkAddress) {
            logger_1.logger.debug(`Device '${payload.ieeeAddr}' is already in database with different network address, updating network address`, NS);
            device.networkAddress = payload.networkAddress;
            device.save();
        }
        device.updateLastSeen();
        this.selfAndDeviceEmit(device, 'lastSeenChanged', { device, reason: 'deviceJoined' });
        device.implicitCheckin();
        if (!device.interviewCompleted && !device.interviewing) {
            logger_1.logger.info(`Interview for '${device.ieeeAddr}' started`, NS);
            this.selfAndDeviceEmit(device, 'deviceInterview', { status: 'started', device });
            try {
                await device.interview();
                logger_1.logger.info(`Succesfully interviewed '${device.ieeeAddr}'`, NS);
                this.selfAndDeviceEmit(device, 'deviceInterview', { status: 'successful', device });
            }
            catch (error) {
                logger_1.logger.error(`Interview failed for '${device.ieeeAddr} with error '${error}'`, NS);
                this.selfAndDeviceEmit(device, 'deviceInterview', { status: 'failed', device });
            }
        }
        else {
            logger_1.logger.debug(`Not interviewing '${payload.ieeeAddr}', completed '${device.interviewCompleted}', in progress '${device.interviewing}'`, NS);
        }
    }
    async onZdoResponse(clusterId, response) {
        logger_1.logger.debug(`Received ZDO response: clusterId=${Zdo.ClusterId[clusterId]}, status=${Zdo.Status[response[0]]}, payload=${JSON.stringify(response[1])}`, NS);
        switch (clusterId) {
            case Zdo.ClusterId.NETWORK_ADDRESS_RESPONSE: {
                if (Zdo.Buffalo.checkStatus(response)) {
                    this.onNetworkAddress(response[1]);
                }
                break;
            }
            case Zdo.ClusterId.IEEE_ADDRESS_RESPONSE: {
                if (Zdo.Buffalo.checkStatus(response)) {
                    this.onIEEEAddress(response[1]);
                }
                break;
            }
            case Zdo.ClusterId.END_DEVICE_ANNOUNCE: {
                if (Zdo.Buffalo.checkStatus(response)) {
                    this.onDeviceAnnounce(response[1]);
                }
                break;
            }
        }
    }
    async onZclPayload(payload) {
        let frame;
        let device;
        if (payload.clusterID === Zcl.Clusters.touchlink.ID) {
            // This is handled by touchlink
            return;
        }
        else if (payload.clusterID === Zcl.Clusters.greenPower.ID) {
            try {
                // Custom clusters are not supported for Green Power since we need to parse the frame to get the device.
                frame = Zcl.Frame.fromBuffer(payload.clusterID, payload.header, payload.data, {});
            }
            catch (error) {
                logger_1.logger.debug(`Failed to parse frame green power frame, ignoring it: ${error}`, NS);
                return;
            }
            // catch potential IEEE-using GP devices by using invalid address
            const nwkAddress = (frame.payload.srcID ?? 0xffff) & 0xffff;
            device = model_1.Device.byNetworkAddress(nwkAddress);
            frame = await this.greenPower.onZclGreenPowerData(payload, frame, device?.gpSecurityKey ? Buffer.from(device.gpSecurityKey) : undefined);
            // lookup encapsulated gpDevice for further processing (re-fetch, may have been created by above call)
            device = model_1.Device.byNetworkAddress(nwkAddress);
            if (!device) {
                logger_1.logger.debug(`Data is from unknown green power device with address '${nwkAddress}' (${frame.payload.srcID}), skipping...`, NS);
                return;
            }
        }
        else {
            /**
             * Handling of re-transmitted Xiaomi messages.
             * https://github.com/Koenkk/zigbee2mqtt/issues/1238
             * https://github.com/Koenkk/zigbee2mqtt/issues/3592
             *
             * Some Xiaomi router devices re-transmit messages from Xiaomi end devices.
             * The network address of these message is set to the one of the Xiaomi router.
             * Therefore it looks like if the message came from the Xiaomi router, while in
             * fact it came from the end device.
             * Handling these message would result in false state updates.
             * The group ID attribute of these message defines the network address of the end device.
             */
            device = model_1.Device.find(payload.address);
            if (device?.manufacturerName === 'LUMI' && device?.type == 'Router' && payload.groupID) {
                logger_1.logger.debug(`Handling re-transmitted Xiaomi message ${device.networkAddress} -> ${payload.groupID}`, NS);
                device = model_1.Device.byNetworkAddress(payload.groupID);
            }
            try {
                frame = Zcl.Frame.fromBuffer(payload.clusterID, payload.header, payload.data, device ? device.customClusters : {});
            }
            catch (error) {
                logger_1.logger.debug(`Failed to parse frame: ${error}`, NS);
            }
        }
        if (!device) {
            if (typeof payload.address === 'number') {
                device = await this.identifyUnknownDevice(payload.address);
            }
            if (!device) {
                logger_1.logger.debug(`Data is from unknown device with address '${payload.address}', skipping...`, NS);
                return;
            }
        }
        logger_1.logger.debug(`Received payload: clusterID=${payload.clusterID}, address=${payload.address}, groupID=${payload.groupID}, ` +
            `endpoint=${payload.endpoint}, destinationEndpoint=${payload.destinationEndpoint}, wasBroadcast=${payload.wasBroadcast}, ` +
            `linkQuality=${payload.linkquality}, frame=${frame?.toString()}`, NS);
        device.updateLastSeen();
        //no implicit checkin for genPollCtrl data because it might interfere with the explicit checkin
        if (!frame?.isCluster('genPollCtrl')) {
            device.implicitCheckin();
        }
        device.linkquality = payload.linkquality;
        let endpoint = device.getEndpoint(payload.endpoint);
        if (!endpoint) {
            logger_1.logger.debug(`Data is from unknown endpoint '${payload.endpoint}' from device with network address '${payload.address}', creating it...`, NS);
            endpoint = device.createEndpoint(payload.endpoint);
        }
        // Parse command for event
        let type;
        let data = {};
        let clusterName;
        const meta = {};
        if (frame) {
            const command = frame.command;
            clusterName = frame.cluster.name;
            meta.zclTransactionSequenceNumber = frame.header.transactionSequenceNumber;
            meta.manufacturerCode = frame.header.manufacturerCode;
            meta.frameControl = frame.header.frameControl;
            if (frame.header.isGlobal) {
                switch (frame.command.name) {
                    case 'report': {
                        type = 'attributeReport';
                        data = helpers_1.ZclFrameConverter.attributeKeyValue(frame, device.manufacturerID, device.customClusters);
                        break;
                    }
                    case 'read': {
                        type = 'read';
                        data = helpers_1.ZclFrameConverter.attributeList(frame, device.manufacturerID, device.customClusters);
                        break;
                    }
                    case 'write': {
                        type = 'write';
                        data = helpers_1.ZclFrameConverter.attributeKeyValue(frame, device.manufacturerID, device.customClusters);
                        break;
                    }
                    case 'readRsp': {
                        type = 'readResponse';
                        data = helpers_1.ZclFrameConverter.attributeKeyValue(frame, device.manufacturerID, device.customClusters);
                        break;
                    }
                }
            }
            else {
                if (frame.header.isSpecific) {
                    type = `command${command.name.charAt(0).toUpperCase()}${command.name.slice(1)}`;
                    data = frame.payload;
                }
            }
            if (type === 'readResponse' || type === 'attributeReport') {
                // Some device report, e.g. it's modelID through a readResponse or attributeReport
                for (const key in data) {
                    const property = model_1.Device.ReportablePropertiesMapping[key];
                    if (property && !device[property.key]) {
                        // XXX: data technically can be `KeyValue | (string | number)[]`
                        property.set(data[key], device);
                    }
                }
                endpoint.saveClusterAttributeKeyValue(frame.cluster.ID, data);
            }
        }
        else {
            type = 'raw';
            data = payload.data;
            const name = Zcl.Utils.getCluster(payload.clusterID, device.manufacturerID, device.customClusters).name;
            clusterName = Number.isNaN(Number(name)) ? name : Number(name);
        }
        if (type && data) {
            const linkquality = payload.linkquality;
            const groupID = payload.groupID;
            this.selfAndDeviceEmit(device, 'message', {
                type,
                device,
                endpoint,
                data,
                linkquality,
                groupID,
                cluster: clusterName,
                meta,
            });
            this.selfAndDeviceEmit(device, 'lastSeenChanged', { device, reason: 'messageEmitted' });
        }
        else {
            this.selfAndDeviceEmit(device, 'lastSeenChanged', { device, reason: 'messageNonEmitted' });
        }
        if (frame) {
            await device.onZclData(payload, frame, endpoint);
        }
    }
}
exports.Controller = Controller;
exports.default = Controller;
//# sourceMappingURL=controller.js.map