UNPKG

zigbee-herdsman

Version:

An open source ZigBee gateway solution with node.js.

789 lines 37.2 kB
"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 (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const assert_1 = __importDefault(require("assert")); const events_1 = __importDefault(require("events")); const fs_1 = __importDefault(require("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 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; permitJoinTimeoutTimer; permitJoinTimeout; 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(); this.permitJoinTimeout = 0; // 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; if (configuredChannel != adapterChannel) { logger_1.logger.info(`Configured channel '${configuredChannel}' does not match adapter channel '${adapterChannel}', changing channel`, NS); await this.changeChannel(adapterChannel, configuredChannel); } } 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)); // 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') { /* istanbul ignore else */ if (this.options.databaseBackupPath && fs_1.default.existsSync(this.options.databasePath)) { 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(); } } if (startResult === 'reset' || (this.options.backupPath && !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); 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, 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))); await this.adapter.addInstallCode(ieeeAddr, key); } async permitJoin(time, device) { clearInterval(this.permitJoinTimeoutTimer); this.permitJoinTimeoutTimer = undefined; this.permitJoinTimeout = 0; if (time > 0) { // never permit more than uint8, and never permit 255 that is often equal to "forever" (0, 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); // TODO: should use setTimeout and timer only for open/close emit // let the other end (frontend) do the sec-by-sec updating (without mqtt publish) // Also likely creates a gap of a few secs between what Z2M says and what the stack actually has => unreliable timer end this.permitJoinTimeout = time; this.permitJoinTimeoutTimer = setInterval(async () => { // assumed valid number while in interval this.permitJoinTimeout--; if (this.permitJoinTimeout <= 0) { clearInterval(this.permitJoinTimeoutTimer); this.permitJoinTimeoutTimer = undefined; this.permitJoinTimeout = 0; this.emit('permitJoinChanged', { permitted: false, timeout: this.permitJoinTimeout }); } else { this.emit('permitJoinChanged', { permitted: true, timeout: this.permitJoinTimeout }); } }, 1000); this.emit('permitJoinChanged', { permitted: true, timeout: this.permitJoinTimeout }); } else { logger_1.logger.debug('Disable joining', NS); await this.greenPower.permitJoin(0); await this.adapter.permitJoin(0); this.emit('permitJoinChanged', { permitted: false, timeout: this.permitJoinTimeout }); } } /** * @returns Timeout until permit joining expires. [0-254], with 0 being "not permitting joining". */ getPermitJoinTimeout() { return this.permitJoinTimeout; } 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'; fs_1.default.writeFileSync(tmpBackupPath, JSON.stringify(unifiedBackup, null, 2)); 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) => `0x${d.ieeeAddress.toString('hex')}`); 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) { logger_1.logger.warning(`Changing channel from '${oldChannel}' to '${newChannel}'`, NS); const clusterId = Zdo.ClusterId.NWK_UPDATE_REQUEST; const zdoPayload = Zdo.Buffalo.buildRequest(this.adapter.hasZdoMessageOverhead, clusterId, [newChannel], 0xfe, undefined, 0, 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); } /** * Set transmit power of the adapter */ async setTransmitPower(value) { return await this.adapter.setTransmitPower(value); } 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); /* istanbul ignore else */ 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)}' joined`, NS); // Green power devices don't have an ieeeAddr, the sourceID is unique and static so use this. let ieeeAddr = payload.sourceID.toString(16); ieeeAddr = `0x${'0'.repeat(16 - ieeeAddr.length)}${ieeeAddr}`; // 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); 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 }); } } selfAndDeviceEmit(device, event, ...args) { device.emit(event, ...args); this.emit(event, ...args); } async onDeviceJoined(payload) { logger_1.logger.debug(`Device '${payload.ieeeAddr}' joined`, NS); /* istanbul ignore else */ 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); /* istanbul ignore else */ 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); 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: { /* istanbul ignore else */ if (Zdo.Buffalo.checkStatus(response)) { this.onNetworkAddress(response[1]); } break; } case Zdo.ClusterId.IEEE_ADDRESS_RESPONSE: { /* istanbul ignore else */ if (Zdo.Buffalo.checkStatus(response)) { this.onIEEEAddress(response[1]); } break; } case Zdo.ClusterId.END_DEVICE_ANNOUNCE: { /* istanbul ignore else */ 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; } await this.greenPower.onZclGreenPowerData(payload, frame); // lookup encapsulated gpDevice for further processing device = model_1.Device.byNetworkAddress(frame.payload.srcID & 0xffff); } 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 { /* istanbul ignore 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.default = Controller; //# sourceMappingURL=controller.js.map