UNPKG

@dotwee/homebridge-z2m

Version:

Expose your Zigbee devices to HomeKit with ease, by integrating Zigbee2MQTT with Homebridge.

535 lines 25.9 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; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Zigbee2mqttPlatform = void 0; const settings_1 = require("./settings"); const platformAccessory_1 = require("./platformAccessory"); const configModels_1 = require("./configModels"); const mqtt = __importStar(require("mqtt")); const fs = __importStar(require("fs")); const z2mModels_1 = require("./z2mModels"); const semver = __importStar(require("semver")); const helpers_1 = require("./helpers"); const creators_1 = require("./converters/creators"); class Zigbee2mqttPlatform { constructor(log, config, api) { this.log = log; this.api = api; // this is used to track restored cached accessories this.accessories = []; this.lastReceivedDevices = []; this.lastReceivedGroups = []; this.groupUpdatePending = false; this.deviceUpdatePending = false; // Prepare internal states, variables and such this.onMessage = this.onMessage.bind(this); this.didReceiveDevices = false; this.lastReceivedZigbee2MqttVersion = undefined; // Set device defaults this.baseDeviceConfig = {}; // Validate configuration if ((0, configModels_1.isPluginConfiguration)(config, creators_1.BasicServiceCreatorManager.getInstance(), log)) { this.config = config; } else { log.error(`INVALID CONFIGURATION FOR PLUGIN: ${settings_1.PLUGIN_NAME}\nThis plugin will NOT WORK until this problem is resolved.`); return; } // Use configuration if (this.config !== undefined) { // Normalize experimental feature flags if (this.config.experimental !== undefined) { this.config.experimental = this.config.experimental.map((feature) => feature.trim().toLocaleUpperCase()); if (this.config.experimental.length > 0) { this.log.warn(`Experimental features enabled: ${this.config.experimental.join(', ')}`); } } // Merge defaults from the plugin configuration if (this.config.defaults !== undefined) { this.baseDeviceConfig = { ...this.baseDeviceConfig, ...this.config.defaults }; } if (this.baseDeviceConfig.exclude === false) { // Set to undefined; as this is already the default behavior and might conflict with exclude_grouped_devices otherwise. this.log.debug('Changing default value for exclude from false to undefined.'); this.baseDeviceConfig.exclude = undefined; } this.log.debug(`Default device config: ${JSON.stringify(this.baseDeviceConfig)}`); this.mqttClient = this.initializeMqttClient(this.config); } } initializeMqttClient(config) { if (!config.mqtt.server || !config.mqtt.base_topic) { this.log.error('No MQTT server and/or base_topic defined!'); } this.log.info(`Connecting to MQTT server at ${config.mqtt.server}`); const options = Zigbee2mqttPlatform.createMqttOptions(this.log, config); const mqttClient = mqtt.connect(config.mqtt.server, options); mqttClient.on('connect', () => { this.log.info('Connected to MQTT server'); setTimeout(() => { if (!this.didReceiveDevices) { this.log.error('DID NOT RECEIVE ANY DEVICES AFTER BEING CONNECTED FOR TWO MINUTES.\n' + `Please verify that Zigbee2MQTT is running and that it is v${Zigbee2mqttPlatform.MIN_Z2M_VERSION} or newer.`); } }, 120000); }); this.api.on('didFinishLaunching', () => { var _a, _b; if (this.config !== undefined) { // Setup MQTT callbacks and subscription (_a = this.mqttClient) === null || _a === void 0 ? void 0 : _a.on('message', this.onMessage); (_b = this.mqttClient) === null || _b === void 0 ? void 0 : _b.subscribe(this.config.mqtt.base_topic + '/#'); } }); return mqttClient; } isExperimentalFeatureEnabled(feature) { var _a; if (((_a = this.config) === null || _a === void 0 ? void 0 : _a.experimental) === undefined) { return false; } return this.config.experimental.includes(feature.trim().toLocaleUpperCase()); } static createMqttOptions(log, config) { const options = {}; if (config.mqtt.version) { options.protocolVersion = config.mqtt.version; } if (config.mqtt.keepalive) { log.debug(`Using MQTT keepalive: ${config.mqtt.keepalive}`); options.keepalive = config.mqtt.keepalive; } if (config.mqtt.ca) { log.debug(`MQTT SSL/TLS: Path to CA certificate = ${config.mqtt.ca}`); options.ca = fs.readFileSync(config.mqtt.ca); } if (config.mqtt.key && config.mqtt.cert) { log.debug(`MQTT SSL/TLS: Path to client key = ${config.mqtt.key}`); log.debug(`MQTT SSL/TLS: Path to client certificate = ${config.mqtt.cert}`); options.key = fs.readFileSync(config.mqtt.key); options.cert = fs.readFileSync(config.mqtt.cert); } if (config.mqtt.user && config.mqtt.password) { options.username = config.mqtt.user; options.password = config.mqtt.password; } if (config.mqtt.client_id) { log.debug(`Using MQTT client ID: '${config.mqtt.client_id}'`); options.clientId = config.mqtt.client_id; } if (config.mqtt.reject_unauthorized !== undefined && !config.mqtt.reject_unauthorized) { log.debug('MQTT reject_unauthorized set false, ignoring certificate warnings.'); options.rejectUnauthorized = false; } return options; } isHomebridgeServerVersionGreaterOrEqualTo(version) { if (this.api.versionGreaterOrEqual !== undefined) { return this.api.versionGreaterOrEqual(version); } return semver.gte(this.api.serverVersion, version); } checkZigbee2MqttVersion(version, topic) { if (version !== this.lastReceivedZigbee2MqttVersion) { // Only log the version if it is different from what we have previously received. this.lastReceivedZigbee2MqttVersion = version; this.log.info(`Using Zigbee2MQTT v${version} (identified via ${topic})`); } // Ignore -dev suffix if present, because Zigbee2MQTT appends this to the latest released version // for the future development build (instead of applying semantic versioning). const strippedVersion = version.replace(/-dev$/, ''); if (semver.lt(strippedVersion, Zigbee2mqttPlatform.MIN_Z2M_VERSION)) { this.log.error('!!! UPDATE OF ZIGBEE2MQTT REQUIRED !!! \n' + `Zigbee2MQTT v${version} is TOO OLD. The minimum required version is v${Zigbee2mqttPlatform.MIN_Z2M_VERSION}. \n` + `This means that ${settings_1.PLUGIN_NAME} MIGHT NOT WORK AS EXPECTED!`); } } onMessage(topic, payload) { var _a, _b; const fullTopic = topic; try { const baseTopic = `${(_a = this.config) === null || _a === void 0 ? void 0 : _a.mqtt.base_topic}/`; if (!topic.startsWith(baseTopic)) { this.log.debug('Ignore message, because topic is unexpected.', topic); return; } topic = topic.substring(baseTopic.length); let updateGroups = false; let updateDevices = false; if (topic.startsWith(Zigbee2mqttPlatform.TOPIC_BRIDGE)) { topic = topic.substring(Zigbee2mqttPlatform.TOPIC_BRIDGE.length); if (topic === 'devices') { // Update accessories this.lastReceivedDevices = JSON.parse(payload.toString()); if (((_b = this.config) === null || _b === void 0 ? void 0 : _b.exclude_grouped_devices) === true) { if (this.lastReceivedGroups.length === 0) { this.deviceUpdatePending = true; } else { updateDevices = true; } } else { updateDevices = true; } if (this.groupUpdatePending) { updateGroups = true; this.groupUpdatePending = false; } } else if (topic === 'groups') { this.lastReceivedGroups = JSON.parse(payload.toString()); if (this.lastReceivedDevices.length === 0) { this.groupUpdatePending = true; } else { updateGroups = true; } if (this.deviceUpdatePending) { updateDevices = true; this.deviceUpdatePending = false; } } else if (topic === 'state') { const state = payload.toString(); if (state === 'offline') { this.log.error('Zigbee2MQTT is OFFLINE!'); // TODO Mark accessories as offline somehow. } } else if (topic === 'info' || topic === 'config') { // New topic (bridge/info) and legacy topic (bridge/config) should both contain the version number. this.checkZigbee2MqttVersionAndConfig(payload.toString(), fullTopic); } } else if (!topic.endsWith('/get') && !topic.endsWith('/set')) { // Probably a status update from a device this.handleDeviceUpdate(topic, payload.toString()); } if (updateDevices) { this.handleReceivedDevices(this.lastReceivedDevices); } if (updateGroups) { this.createGroupAccessories(this.lastReceivedGroups); } if (updateDevices || updateGroups) { this.removeStaleDevices(); } } catch (Error) { this.log.error(`Failed to process MQTT message on '${fullTopic}'. (Maybe check the MQTT version?)`); this.log.error((0, helpers_1.errorToString)(Error)); } } checkZigbee2MqttVersionAndConfig(payload, fullTopic) { var _a; const info = JSON.parse(payload); if ('version' in info) { if (info.version !== this.lastReceivedZigbee2MqttVersion) { // Only log the version if it is different from what we have previously received. this.lastReceivedZigbee2MqttVersion = info.version; this.log.info(`Using Zigbee2MQTT v${info.version} (identified via ${fullTopic})`); } // Ignore -dev suffix if present, because Zigbee2MQTT appends this to the latest released version // for the future development build (instead of applying semantic versioning). const strippedVersion = info.version.replace(/-dev$/, ''); if (semver.lt(strippedVersion, Zigbee2mqttPlatform.MIN_Z2M_VERSION)) { this.log.error('!!!UPDATE OF ZIGBEE2MQTT REQUIRED!!! \n' + `Zigbee2MQTT v${info.version} is TOO OLD. The minimum required version is v${Zigbee2mqttPlatform.MIN_Z2M_VERSION}. \n` + `This means that ${settings_1.PLUGIN_NAME} MIGHT NOT WORK AS EXPECTED!`); } } else { this.log.error(`No version found in message on '${fullTopic}'.`); } // Also check for potentially incorrect configurations: if ('config' in info) { const outputFormat = (_a = info.config.experimental) === null || _a === void 0 ? void 0 : _a.output; if (outputFormat !== undefined) { if (!outputFormat.includes('json')) { this.log.error('Zigbee2MQTT MUST output JSON in order for this plugin to work correctly. ' + `Currently 'experimental.output' is set to '${outputFormat}'. Please adjust your configuration.`); } else { this.log.debug(`Zigbee2MQTT 'experimental.output' is set to '${outputFormat}'`); } } } } async handleDeviceUpdate(topic, statePayload) { if (statePayload === '') { this.log.debug('Ignore update, because payload is empty.', topic); return; } const accessory = this.accessories.find((acc) => acc.matchesIdentifier(topic)); if (accessory) { try { const state = JSON.parse(statePayload); accessory.updateStates(state); this.log.debug(`Handled device update for ${topic}: ${statePayload}`); } catch (Error) { this.log.error(`Failed to process status update with payload: ${statePayload}`); this.log.error((0, helpers_1.errorToString)(Error)); } } else { this.log.debug(`Unhandled message on topic: ${topic}`); } } removeStaleDevices() { // Remove devices that are no longer present const staleAccessories = []; for (let i = this.accessories.length - 1; i >= 0; --i) { const foundIndex = this.lastReceivedDevices.findIndex((d) => d.ieee_address === this.accessories[i].ieeeAddress); const foundGroupIndex = this.lastReceivedGroups.findIndex((g) => g.id === this.accessories[i].groupId); if ((foundIndex < 0 && foundGroupIndex < 0) || this.isDeviceExcluded(this.accessories[i].accessory.context.device)) { // Not found or excluded; remove it. this.log.debug(`Removing accessory ${this.accessories[i].displayName} (${this.accessories[i].ieeeAddress})`); staleAccessories.push(this.accessories[i].accessory); this.accessories.splice(i, 1); } } if (staleAccessories.length > 0) { this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, staleAccessories); } } handleReceivedDevices(devices) { this.log.debug('Received devices...'); this.didReceiveDevices = true; devices.forEach((d) => this.createOrUpdateAccessory(d)); } configureAccessory(accessory) { this.addAccessory(accessory); } // eslint-disable-next-line @typescript-eslint/no-explicit-any static getIdentifiersFromDevice(device) { const identifiers = []; if (typeof device === 'string') { identifiers.push(device.toLocaleLowerCase()); } else { if ('ieee_address' in device) { identifiers.push(device.ieee_address.toLocaleLowerCase()); } if ('friendly_name' in device) { identifiers.push(device.friendly_name.toLocaleLowerCase()); } if ('id' in device) { identifiers.push(device.id.toString().toLocaleLowerCase()); } } return identifiers; } // eslint-disable-next-line @typescript-eslint/no-explicit-any getAdditionalConfigForDevice(device) { var _a; if (((_a = this.config) === null || _a === void 0 ? void 0 : _a.devices) !== undefined) { const identifiers = Zigbee2mqttPlatform.getIdentifiersFromDevice(device); for (const devConfig of this.config.devices) { if (identifiers.includes(devConfig.id.toLocaleLowerCase())) { return this.mergeDeviceConfig(devConfig); } } } return this.baseDeviceConfig; } mergeDeviceConfig(devConfig) { const result = { ...this.baseDeviceConfig, ...devConfig }; // Merge converter configs correctly if (this.baseDeviceConfig.converters !== undefined && devConfig.converters !== undefined) { result.converters = { ...this.baseDeviceConfig.converters, ...devConfig.converters }; } if (result.experimental !== undefined) { // Normalize experimental feature flags result.experimental = result.experimental.map((feature) => feature.trim().toLocaleUpperCase()); } return result; } isDeviceExcluded(device) { var _a; const additionalConfig = this.getAdditionalConfigForDevice(device); if ((additionalConfig === null || additionalConfig === void 0 ? void 0 : additionalConfig.exclude) === true) { this.log.debug(`Device is excluded: ${additionalConfig.id}`); return true; } if ((additionalConfig === null || additionalConfig === void 0 ? void 0 : additionalConfig.exclude) === false) { // Device is explicitly NOT excluded (via device config or default device config) return false; } if (((_a = this.config) === null || _a === void 0 ? void 0 : _a.exclude_grouped_devices) === true && this.lastReceivedGroups !== undefined) { const id = typeof device === 'string' ? device : device.ieee_address; for (const group of this.lastReceivedGroups) { if (group.members.findIndex((m) => m.ieee_address === id) >= 0) { this.log.debug(`Device (${id}) is excluded because it is in a group: ${group.friendly_name} (${group.id})`); return true; } } } return false; } addAccessory(accessory) { var _a; const ieee_address = (_a = accessory.context.device.ieee_address) !== null && _a !== void 0 ? _a : accessory.context.device.ieeeAddr; if (this.isDeviceExcluded(accessory.context.device)) { this.log.warn(`Excluded device found on startup: ${accessory.context.device.friendly_name} (${ieee_address}).`); process.nextTick(() => { try { this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]); } catch (error) { this.log.error('Failed to delete accessory.'); this.log.error((0, helpers_1.errorToString)(error)); } }); return; } if (!(0, z2mModels_1.isDeviceListEntry)(accessory.context.device)) { this.log.warn(`Restoring old (pre v1.0.0) accessory ${accessory.context.device.friendly_name} (${ieee_address}). This accessory ` + `will not work until updated device information is received from Zigbee2MQTT v${Zigbee2mqttPlatform.MIN_Z2M_VERSION} or newer.`); } if (this.accessories.findIndex((acc) => acc.UUID === accessory.UUID) < 0) { // New entry this.log.info(`Restoring accessory: ${accessory.displayName} (${ieee_address})`); const acc = new platformAccessory_1.Zigbee2mqttAccessory(this, accessory, this.getAdditionalConfigForDevice(accessory.context.device)); this.accessories.push(acc); } } createOrUpdateAccessory(device) { if (!device.supported || device.definition === undefined || this.isDeviceExcluded(device)) { return; } const uuid_input = (0, z2mModels_1.isDeviceListEntryForGroup)(device) ? `group-${device.group_id}` : device.ieee_address; const uuid = this.api.hap.uuid.generate(uuid_input); const existingAcc = this.accessories.find((acc) => acc.UUID === uuid); if (existingAcc) { existingAcc.updateDeviceInformation(device); } else { // New entry this.log.info('New accessory:', device.friendly_name); const accessory = new this.api.platformAccessory(device.friendly_name, uuid); accessory.context.device = device; this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]); const acc = new platformAccessory_1.Zigbee2mqttAccessory(this, accessory, this.getAdditionalConfigForDevice(device)); this.accessories.push(acc); } } isConnected() { return this.mqttClient !== undefined && !this.mqttClient.reconnecting; } async publishMessage(topic, payload, options) { var _a, _b, _c; if (this.config !== undefined) { topic = `${this.config.mqtt.base_topic}/${topic}`; options = { qos: 0, retain: false, ...options }; if (!this.isConnected) { this.log.error('Not connected to MQTT server!'); this.log.error(`Cannot send message to '${topic}': '${payload}`); return; } this.log.log(((_c = (_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.log) === null || _b === void 0 ? void 0 : _b.mqtt_publish) !== null && _c !== void 0 ? _c : "debug" /* LogLevel.DEBUG */), `Publish to '${topic}': '${payload}'`); return new Promise((resolve) => { var _a; (_a = this.mqttClient) === null || _a === void 0 ? void 0 : _a.publish(topic, payload, options, () => resolve()); }); } } createGroupAccessories(groups) { this.log.debug('Received groups...'); for (const group of groups) { const device = this.createDeviceListEntryFromGroup(group); if (device !== undefined) { this.createOrUpdateAccessory(device); } } } createDeviceListEntryFromGroup(group) { let exposes = this.determineExposesForGroup(group); if (exposes.length === 0) { // No exposes found. Check if additional config is given. const config = this.getAdditionalConfigForDevice(group); if (config !== undefined && (config.exclude === undefined || config.exclude === false) && (0, configModels_1.isDeviceConfiguration)(config) && config.exposes !== undefined && config.exposes.length > 0) { // Additional config is given and it is not excluded. exposes = config.exposes; } else { // No exposes info found, so can't expose the group this.log.debug(`Group ${group.friendly_name} (${group.id}) has no usable exposes information.`); return undefined; } } else { // Exposes found. this.log.debug(`Group ${group.friendly_name} (${group.id}) exposes (auto-determined):\n${JSON.stringify(exposes, null, 2)}`); } const device = { friendly_name: group.friendly_name, ieee_address: group.id.toString(), group_id: group.id, supported: true, definition: { vendor: 'Zigbee2MQTT', model: `GROUP-${group.id}`, exposes: exposes, }, }; return device; } determineExposesForGroup(group) { var _a; let exposes = []; let firstEntry = true; for (const member of group.members) { const device = this.lastReceivedDevices.find((dev) => dev.ieee_address === member.ieee_address); if (device === undefined) { this.log.warn(`Cannot find group member in devices: ${member.ieee_address}`); continue; } if (((_a = device.definition) === null || _a === void 0 ? void 0 : _a.exposes) === undefined) { this.log.warn(`No exposes info for group member: ${member.ieee_address}`); continue; } if (firstEntry) { // Exclude link quality information exposes = device.definition.exposes.filter((e) => e.name !== 'linkquality'); firstEntry = false; } else { // Try to merge exposes exposes = (0, z2mModels_1.exposesGetOverlap)(exposes, device.definition.exposes); } } return exposes; } } exports.Zigbee2mqttPlatform = Zigbee2mqttPlatform; Zigbee2mqttPlatform.MIN_Z2M_VERSION = '1.17.0'; Zigbee2mqttPlatform.TOPIC_BRIDGE = 'bridge/'; //# sourceMappingURL=platform.js.map