diff --git a/.travis.yml b/.travis.yml index 8f69d8d1d6..daa91ae9a1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,7 +13,6 @@ install: before_script: - npm test - - npm run verify-homeassistant-mapping - npm run eslint script: diff --git a/lib/controller.js b/lib/controller.js index 87026f5527..5e2a29632e 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -3,451 +3,208 @@ const Zigbee = require('./zigbee'); const State = require('./state'); const logger = require('./util/logger'); const settings = require('./util/settings'); +const zigbeeShepherdConverters = require('zigbee-shepherd-converters'); +const objectAssignDeep = require('object-assign-deep'); + +// Extensions const ExtensionNetworkMap = require('./extension/networkMap'); const ExtensionSoftReset = require('./extension/softReset'); const ExtensionRouterPollXiaomi = require('./extension/routerPollXiaomi'); const ExtensionDevicePublish = require('./extension/devicePublish'); -const zigbeeShepherdConverters = require('zigbee-shepherd-converters'); -const homeassistant = require('./homeassistant'); -const objectAssignDeep = require('object-assign-deep'); - -const mqttConfigRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/\\w+`, 'g'); - -const allowedLogLevels = ['error', 'warn', 'info', 'debug']; - -/** - * Home Assistant requires ALL attributes to be present in ALL MQTT messages send by the device. - * https://community.home-assistant.io/t/missing-value-with-mqtt-only-last-data-set-is-shown/47070/9 - * - * Therefore zigbee2mqtt BY DEFAULT caches all values and resend it with every message. - * advanced.cache_state in configuration.yaml allows to configure this. - * https://github.com/Koenkk/zigbee2mqtt/wiki/Configuration - */ -const cacheState = settings.get().advanced && settings.get().advanced.cache_state === false ? false : true; -if (settings.get().homeassistant && !cacheState) { - logger.warn('In order for Home Assistant integration to work properly set `cache_state: true'); -} +const ExtensionHomeAssistant = require('./extension/homeassistant'); +const ExtensionDeviceConfigure = require('./extension/deviceConfigure'); +const ExtensionDeviceReceive = require('./extension/deviceReceive'); +const ExtensionMarkOnlineXiaomi = require('./extension/markOnlineXiaomi'); +const ExtensionBridgeConfig = require('./extension/bridgeConfig'); class Controller { constructor() { - this.handleZigbeeMessage = this.handleZigbeeMessage.bind(this); - this.handleMQTTMessage = this.handleMQTTMessage.bind(this); - - this.zigbee = new Zigbee(this.handleZigbeeMessage); + this.zigbee = new Zigbee(); this.mqtt = new MQTT(); this.state = new State(); - this.configured = []; - this.extensions = []; - } - start() { - this.startupLogVersion(() => { - this.zigbee.start((error) => { - if (error) { - logger.error('Failed to start', error); - } else { - // Log zigbee clients on startup and configure. - const devices = this.zigbee.getAllClients(); - logger.info(`Currently ${devices.length} devices are joined:`); - devices.forEach((device) => { - logger.info(this.getDeviceStartupLogMessage(device)); - this.configureDevice(device); - }); - - // Enable zigbee join. - if (settings.get().permit_join) { - logger.warn('`permit_join` set to `true` in configuration.yaml.'); - logger.warn('Allowing new devices to join.'); - logger.warn('Set `permit_join` to `false` once you joined all devices.'); - } - - this.zigbee.permitJoin(settings.get().permit_join); - - // Connect to MQTT broker - const subscriptions = [ - `${settings.get().mqtt.base_topic}/bridge/config/+`, - ]; - - if (settings.get().homeassistant) { - subscriptions.push('hass/status'); - } - - this.mqtt.connect(this.handleMQTTMessage, subscriptions, () => this.handleMQTTConnected()); - } - }); - }); - } - - handleMQTTConnected() { - // Home Assistant MQTT discovery on MQTT connected. - if (settings.get().homeassistant) { - // MQTT discovery of all paired devices on startup. - this.zigbee.getAllClients().forEach((device) => { - const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId); - if (mappedModel) { - homeassistant.discover(device.ieeeAddr, mappedModel.model, this.mqtt, true); - } - }); - } + // Bind methods + this.onMQTTConnected = this.onMQTTConnected.bind(this); + this.onZigbeeMessage = this.onZigbeeMessage.bind(this); + this.onMQTTMessage = this.onMQTTMessage.bind(this); // Initialize extensions. this.extensions = [ - new ExtensionDevicePublish(this.zigbee, this.mqtt, this.state, this.mqttPublishDeviceState), - new ExtensionNetworkMap(this.zigbee, this.mqtt, this.state, this.mqttPublishDeviceState), - new ExtensionSoftReset(this.zigbee, this.mqtt, this.state, this.mqttPublishDeviceState), - new ExtensionRouterPollXiaomi(this.zigbee, this.mqtt, this.state, this.mqttPublishDeviceState), + new ExtensionDeviceReceive(this.zigbee, this.mqtt, this.state, this.publishDeviceState), + new ExtensionDeviceConfigure(this.zigbee, this.mqtt, this.state, this.publishDeviceState), + new ExtensionDevicePublish(this.zigbee, this.mqtt, this.state, this.publishDeviceState), + new ExtensionNetworkMap(this.zigbee, this.mqtt, this.state, this.publishDeviceState), + new ExtensionRouterPollXiaomi(this.zigbee, this.mqtt, this.state, this.publishDeviceState), + new ExtensionMarkOnlineXiaomi(this.zigbee, this.mqtt, this.state, this.publishDeviceState), + new ExtensionBridgeConfig(this.zigbee, this.mqtt, this.state, this.publishDeviceState), ]; - // Resend all cached states. - this.sendAllCachedStates(); - } - - sendAllCachedStates() { - this.zigbee.getAllClients().forEach((device) => { - if (this.state.exists(device.ieeeAddr)) { - this.mqttPublishDeviceState(device, this.state.get(device.ieeeAddr), false); - } - }); - } - - stop(callback) { - this.extensions.filter((e) => e.stop).forEach((e) => e.stop()); - this.state.save(); - this.mqtt.disconnect(); - this.zigbee.stop(callback); - } - - configureDevice(device) { - let friendlyName = 'unknown'; - const ieeeAddr = device.ieeeAddr; - if (settings.getDevice(ieeeAddr)) { - friendlyName = settings.getDevice(ieeeAddr).friendly_name; - } - if (ieeeAddr && device.modelId && !this.configured.includes(ieeeAddr)) { - const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId); - - // Call configure function of device. - if (mappedModel && mappedModel.configure) { - mappedModel.configure(ieeeAddr, this.zigbee.shepherd, this.zigbee.getCoordinator(), (ok, msg) => { - if (ok) { - logger.info(`Succesfully configured ${friendlyName} ${ieeeAddr}`); - } else { - logger.error(`Failed to configure ${friendlyName} ${ieeeAddr}`); - } - }); - } - - // Setup an OnAfIncomingMsg handler if needed. - if (mappedModel && mappedModel.onAfIncomingMsg) { - mappedModel.onAfIncomingMsg.forEach((ep) => this.zigbee.registerOnAfIncomingMsg(ieeeAddr, ep)); - } - - this.configured.push(ieeeAddr); - } - } - - getDeviceStartupLogMessage(device) { - let friendlyName = 'unknown'; - let type = 'unknown'; - let friendlyDevice = {model: 'unkown', description: 'unknown'}; - const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId); - if (mappedModel) { - friendlyDevice = mappedModel; - } - - if (settings.getDevice(device.ieeeAddr)) { - friendlyName = settings.getDevice(device.ieeeAddr).friendly_name; + if (settings.get().homeassistant) { + this.extensions.push(new ExtensionHomeAssistant( + this.zigbee, this.mqtt, this.state, this.publishDeviceState + )); } - if (device.type) { - type = device.type; + if (settings.get().advanced.soft_reset_timeout !== 0) { + this.extensions.push(new ExtensionSoftReset( + this.zigbee, this.mqtt, this.state, this.publishDeviceState + )); } - - return `${friendlyName} (${device.ieeeAddr}): ${friendlyDevice.model} - ` + - `${friendlyDevice.vendor} ${friendlyDevice.description} (${type})`; } - getDeviceInfoForMqtt(device) { - const {type, ieeeAddr, nwkAddr, manufId, manufName, powerSource, modelId, status} = device; - const deviceSettings = settings.getDevice(device.ieeeAddr); + onMQTTConnected() { + // Resend all cached states. + this.sendAllCachedStates(); - return { - ieeeAddr, - friendlyName: deviceSettings.friendly_name || '', - type, - nwkAddr, - manufId, - manufName, - powerSource, - modelId, - status, - }; + // Call extensions + this.extensions.filter((e) => e.onMQTTConnected).forEach((e) => e.onMQTTConnected()); } - handleZigbeeMessage(message) { - // Call extensions. - this.extensions.filter((e) => e.handleZigbeeMessage).forEach((e) => e.handleZigbeeMessage(message)); + onZigbeeStarted() { + // Log zigbee clients on startup and configure. + const devices = this.zigbee.getAllClients(); + logger.info(`Currently ${devices.length} devices are joined:`); + devices.forEach((device) => { + logger.info(this.getDeviceStartupLogMessage(device)); + }); - // Log the message. - let logMessage = `Received zigbee message of type '${message.type}' ` + - `with data '${JSON.stringify(message.data)}'`; - if (message.endpoints && message.endpoints[0].device) { - const device = message.endpoints[0].device; - logMessage += ` of device '${device.modelId}' (${device.ieeeAddr})`; + // Enable zigbee join. + if (settings.get().permit_join) { + logger.warn('`permit_join` set to `true` in configuration.yaml.'); + logger.warn('Allowing new devices to join.'); + logger.warn('Set `permit_join` to `false` once you joined all devices.'); } - logger.debug(logMessage); - if (message.type == 'devInterview' && !settings.getDevice(message.data)) { - logger.info('Connecting with device...'); - this.mqtt.log('pairing', 'connecting with device'); - } + this.zigbee.permitJoin(settings.get().permit_join); - if (message.type == 'devIncoming') { - logger.info('Device incoming...'); - this.mqtt.log('pairing', 'device incoming'); - } + // Connect to MQTT broker + this.mqtt.connect(this.onMQTTMessage, this.onMQTTConnected); - // We dont handle messages without endpoints. - if (!message.endpoints) { - return; - } + // Call extensions + this.extensions.filter((e) => e.onZigbeeStarted).forEach((e) => e.onZigbeeStarted()); + } - const device = message.endpoints[0].device; + onZigbeeMessage(message) { + // Variables + let device = null; + let mappedDevice = null; - if (!device) { - logger.warn('Message without device!'); - return; + // Check if message has a device + if (message.endpoints && message.endpoints[0].device) { + device = message.endpoints[0].device; } - // Check if this is a new device. - if (!settings.getDevice(device.ieeeAddr)) { - logger.info(`New device with address ${device.ieeeAddr} connected!`); - settings.addDevice(device.ieeeAddr); - this.mqtt.log('device_connected', device.ieeeAddr); + // Retrieve modelId from message + if (device && device.modelId) { + mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId); } - // We can't handle devices without modelId. - if (!device.modelId) { - return; - } + // Log + logger.debug( + `Received zigbee message of type '${message.type}' with data '${JSON.stringify(message.data)}'` + + (device ? ` of device '${device.modelId}' (${device.ieeeAddr})` : '') + ); - // Map Zigbee modelID to vendor modelID. - const modelID = message.endpoints[0].device.modelId; - const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(modelID); + // Call extensions. + this.extensions + .filter((e) => e.onZigbeeMessage) + .forEach((e) => e.onZigbeeMessage(message, device, mappedDevice)); + } - if (!mappedModel) { - logger.warn(`Device with modelID '${modelID}' is not supported.`); - logger.warn(`Please see: https://github.com/Koenkk/zigbee2mqtt/wiki/How-to-support-new-devices`); - return; - } + onMQTTMessage(topic, message) { + logger.debug(`Received MQTT message on '${topic}' with data '${message}'`); - // Configure device. - this.configureDevice(device); + // Call extensions + const results = this.extensions + .filter((e) => e.onMQTTMessage) + .map((e) => e.onMQTTMessage(topic, message)); - // Home Assistant MQTT discovery - if (settings.get().homeassistant) { - homeassistant.discover(device.ieeeAddr, mappedModel.model, this.mqtt, false); + if (!results.includes(true)) { + logger.warn(`Cannot handle MQTT message on '${topic}' with data '${message}'`); } + } - // After this point we cant handle message withoud cid anymore. - if (!message.data || (!message.data.cid && !message.data.cmdId)) { - return; - } + start() { + this.startupLogVersion(() => { + this.zigbee.start(this.onZigbeeMessage, (error) => { + if (error) { + logger.error('Failed to start', error); + } else { + this.onZigbeeStarted(); + } + }); + }); + } - // Find a conveter for this message. - const cid = message.data.cid; - const cmdId = message.data.cmdId; - const converters = mappedModel.fromZigbee.filter((c) => { - if (cid) { - return c.cid === cid && c.type === message.type; - } else if (cmdId) { - return c.cmd === cmdId; - } + stop(callback) { + // Call extensions + this.extensions.filter((e) => e.stop).forEach((e) => e.stop()); - return false; - }); + // Wrap-up + this.state.save(); + this.mqtt.disconnect(); + this.zigbee.stop(callback); + } - if (!converters.length) { - if (cid) { - logger.warn( - `No converter available for '${mappedModel.model}' with cid '${cid}', ` + - `type '${message.type}' and data '${JSON.stringify(message.data)}'` - ); - } else if (cmdId) { - logger.warn( - `No converter available for '${mappedModel.model}' with cmd '${cmdId}' ` + - `and data '${JSON.stringify(message.data)}'` - ); - } + startupLogVersion(callback) { + const git = require('git-last-commit'); + const packageJSON = require('../package.json'); + const version = packageJSON.version; - logger.warn(`Please see: https://github.com/Koenkk/zigbee2mqtt/wiki/How-to-support-new-devices.`); - return; - } + git.getLastCommit((err, commit) => { + let commitHash = null; - // Convert this Zigbee message to a MQTT message. - // Get payload for the message. - // - If a payload is returned publish it to the MQTT broker - // - If NO payload is returned do nothing. This is for non-standard behaviour - // for e.g. click switches where we need to count number of clicks and detect long presses. - converters.forEach((converter) => { - const publish = (payload) => { - // Don't cache messages with following properties: - const dontCacheProperties = ['click', 'action', 'button', 'button_left', 'button_right']; - let cache = true; - dontCacheProperties.forEach((property) => { - if (payload.hasOwnProperty(property)) { - cache = false; - } - }); - - // Add device linkquality. - if (message.hasOwnProperty('linkquality')) { - payload.linkquality = message.linkquality; + if (err) { + try { + commitHash = require('../.hash.json').hash; + } catch (error) { + commitHash = 'unknown'; } + } else { + commitHash = commit.shortHash; + } - this.mqttPublishDeviceState(device, payload, cache); - }; - - const payload = converter.convert(mappedModel, message, publish, settings.getDevice(device.ieeeAddr)); + logger.info(`Starting zigbee2mqtt version ${version} (commit #${commitHash})`); - if (payload) { - publish(payload); - } + callback(); }); } - handleMQTTMessage(topic, message) { - logger.debug(`Received MQTT message on '${topic}' with data '${message}'`); - - // Find extensions that can handle MQTT messages and get results - const results = this.extensions - .filter((e) => e.handleMQTTMessage) - .map((e) => e.handleMQTTMessage(topic, message)); - - if (topic.match(mqttConfigRegex)) { - this.handleMQTTMessageConfig(topic, message); - } else if (topic === 'hass/status') { - if (message.toString().toLowerCase() === 'online') { - const timer = setTimeout(() => { - this.sendAllCachedStates(); - clearTimeout(timer); - }, 20000); - } - } else if (!results.includes(true)) { - logger.warn(`Cannot handle MQTT message on '${topic}' with data '${message}'`); + getDeviceStartupLogMessage(device) { + let friendlyName = 'unknown'; + let type = 'unknown'; + let friendlyDevice = {model: 'unkown', description: 'unknown'}; + const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId); + if (mappedModel) { + friendlyDevice = mappedModel; } - } - handleMQTTMessageConfig(topic, message) { - const option = topic.split('/').slice(-1)[0]; - - if (option === 'permit_join') { - this.zigbee.permitJoin(message.toString().toLowerCase() === 'true'); - } else if (option === 'log_level') { - const level = message.toString().toLowerCase(); - if (allowedLogLevels.includes(level)) { - logger.info(`Switching log level to '${level}'`); - logger.transports.console.level = level; - logger.transports.file.level = level; - } else { - logger.error(`Could not set log level to '${level}'. Allowed level: '${allowedLogLevels.join(',')}'`); - } - } else if (option === 'devices') { - const devices = this.zigbee.getAllClients().map((device) => { - const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId); - const friendlyDevice = settings.getDevice(device.ieeeAddr); - - return { - ieeeAddr: device.ieeeAddr, - type: device.type, - model: mappedModel ? mappedModel.model : device.modelId, - friendly_name: friendlyDevice ? friendlyDevice.friendly_name : device.ieeeAddr, - }; - }); - - this.mqtt.log('devices', devices); - } else if (option === 'remove') { - message = message.toString(); - const IDByFriendlyName = settings.getIDByFriendlyName(message); - const deviceID = IDByFriendlyName ? IDByFriendlyName : message; - const device = this.zigbee.getDevice(deviceID); - - const cleanup = () => { - // Clear Home Assistant MQTT discovery message - if (settings.get().homeassistant && device) { - const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId); - if (mappedModel) { - homeassistant.clear(deviceID, mappedModel.model, this.mqtt); - } - } - - // Remove from configuration.yaml - settings.removeDevice(deviceID); - - // Remove from state - this.state.remove(deviceID); - - logger.info(`Successfully removed ${deviceID}`); - this.mqtt.log('device_removed', message); - }; - - // Remove from zigbee network. - if (device) { - this.zigbee.removeDevice(deviceID, (error) => { - if (!error) { - cleanup(); - } else { - logger.error(`Failed to remove ${deviceID}`); - } - }); - } else { - cleanup(); - } - } else if (option === 'rename') { - const invalid = `Invalid rename message format expected {old: 'friendly_name', new: 'new_name} ` + - `got ${message.toString()}`; - - let json = null; - try { - json = JSON.parse(message.toString()); - } catch (e) { - logger.error(invalid); - return; - } + if (settings.getDevice(device.ieeeAddr)) { + friendlyName = settings.getDevice(device.ieeeAddr).friendly_name; + } - // Validate message - if (!json.new || !json.old) { - logger.error(invalid); - return; - } + if (device.type) { + type = device.type; + } - if (settings.changeFriendlyName(json.old, json.new)) { - logger.info(`Successfully renamed - ${json.old} to ${json.new} `); - } else { - logger.error(`Failed to renamed - ${json.old} to ${json.new}`); - return; - } + return `${friendlyName} (${device.ieeeAddr}): ${friendlyDevice.model} - ` + + `${friendlyDevice.vendor} ${friendlyDevice.description} (${type})`; + } - // Homeassistant rediscover - if (settings.get().homeassistant) { - const ID = settings.getIDByFriendlyName(json.new); - const device = this.zigbee.getDevice(ID); - const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId); - if (mappedModel) { - homeassistant.discover(device.ieeeAddr, mappedModel.model, this.mqtt, true); - } + sendAllCachedStates() { + this.zigbee.getAllClients().forEach((device) => { + if (this.state.exists(device.ieeeAddr)) { + this.publishDeviceState(device, this.state.get(device.ieeeAddr), false); } - } else { - logger.warn(`Cannot handle MQTT config option '${option}' with message '${message}'`); - } + }); } - mqttPublishDeviceState(device, payload, cache) { + publishDeviceState(device, payload, cache) { const deviceID = device.ieeeAddr; const appSettings = settings.get(); let messagePayload = {...payload}; - if (cacheState) { + if (appSettings.advanced.cache_state) { // Add cached state to payload if (this.state.exists(deviceID)) { messagePayload = objectAssignDeep.noMutate(this.state.get(deviceID), payload); @@ -473,28 +230,21 @@ class Controller { this.mqtt.publish(friendlyName, JSON.stringify(messagePayload), options); } - startupLogVersion(callback) { - const git = require('git-last-commit'); - const packageJSON = require('../package.json'); - const version = packageJSON.version; - - git.getLastCommit((err, commit) => { - let commitHash = null; - - if (err) { - try { - commitHash = require('../.hash.json').hash; - } catch (error) { - commitHash = 'unknown'; - } - } else { - commitHash = commit.shortHash; - } - - logger.info(`Starting zigbee2mqtt version ${version} (commit #${commitHash})`); + getDeviceInfoForMqtt(device) { + const {type, ieeeAddr, nwkAddr, manufId, manufName, powerSource, modelId, status} = device; + const deviceSettings = settings.getDevice(device.ieeeAddr); - callback(); - }); + return { + ieeeAddr, + friendlyName: deviceSettings.friendly_name || '', + type, + nwkAddr, + manufId, + manufName, + powerSource, + modelId, + status, + }; } } diff --git a/lib/extension/bridgeConfig.js b/lib/extension/bridgeConfig.js new file mode 100644 index 0000000000..e2382a4796 --- /dev/null +++ b/lib/extension/bridgeConfig.js @@ -0,0 +1,141 @@ +const settings = require('../util/settings'); +const logger = require('../util/logger'); +const zigbeeShepherdConverters = require('zigbee-shepherd-converters'); + +const configRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/\\w+`, 'g'); +const allowedLogLevels = ['error', 'warn', 'info', 'debug']; + +class BridgeConfig { + constructor(zigbee, mqtt, state, publishDeviceState) { + this.zigbee = zigbee; + this.mqtt = mqtt; + this.state = state; + this.publishDeviceState = publishDeviceState; + + // Bind functions + this.permitJoin = this.permitJoin.bind(this); + this.logLevel = this.logLevel.bind(this); + this.devices = this.devices.bind(this); + this.rename = this.rename.bind(this); + this.remove = this.remove.bind(this); + + // Set supported options + this.supportedOptions = { + 'permit_join': this.permitJoin, + 'log_level': this.logLevel, + 'devices': this.devices, + 'rename': this.rename, + 'remove': this.remove, + }; + } + + permitJoin(topic, message) { + this.zigbee.permitJoin(message.toString().toLowerCase() === 'true'); + } + + logLevel(topic, message) { + const level = message.toString().toLowerCase(); + if (allowedLogLevels.includes(level)) { + logger.info(`Switching log level to '${level}'`); + logger.transports.console.level = level; + logger.transports.file.level = level; + } else { + logger.error(`Could not set log level to '${level}'. Allowed level: '${allowedLogLevels.join(',')}'`); + } + } + + devices(topic, message) { + const devices = this.zigbee.getAllClients().map((device) => { + const mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId); + const friendlyDevice = settings.getDevice(device.ieeeAddr); + + return { + ieeeAddr: device.ieeeAddr, + type: device.type, + model: mappedDevice ? mappedDevice.model : device.modelId, + friendly_name: friendlyDevice ? friendlyDevice.friendly_name : device.ieeeAddr, + }; + }); + + this.mqtt.log('devices', devices); + } + + rename(topic, message) { + const invalid = `Invalid rename message format expected {old: 'friendly_name', new: 'new_name} ` + + `got ${message.toString()}`; + + let json = null; + try { + json = JSON.parse(message.toString()); + } catch (e) { + logger.error(invalid); + return; + } + + // Validate message + if (!json.new || !json.old) { + logger.error(invalid); + return; + } + + if (settings.changeFriendlyName(json.old, json.new)) { + logger.info(`Successfully renamed - ${json.old} to ${json.new} `); + } else { + logger.error(`Failed to renamed - ${json.old} to ${json.new}`); + return; + } + } + + remove(topic, message) { + message = message.toString(); + const IDByFriendlyName = settings.getIeeeAddrByFriendlyName(message); + const deviceID = IDByFriendlyName ? IDByFriendlyName : message; + const device = this.zigbee.getDevice(deviceID); + + const cleanup = () => { + // Remove from configuration.yaml + settings.removeDevice(deviceID); + + // Remove from state + this.state.remove(deviceID); + + logger.info(`Successfully removed ${deviceID}`); + this.mqtt.log('device_removed', message); + }; + + // Remove from zigbee network. + if (device) { + this.zigbee.removeDevice(deviceID, (error) => { + if (!error) { + cleanup(); + } else { + logger.error(`Failed to remove ${deviceID}`); + } + }); + } else { + cleanup(); + } + } + + onMQTTConnected() { + this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/config/+`); + } + + onMQTTMessage(topic, message) { + if (!topic.match(configRegex)) { + return false; + } + + const option = topic.split('/').slice(-1)[0]; + + if (!this.supportedOptions.hasOwnProperty(option)) { + return false; + } + + this.supportedOptions[option](topic, message); + + return true; + } +} + +module.exports = BridgeConfig; diff --git a/lib/extension/deviceConfigure.js b/lib/extension/deviceConfigure.js new file mode 100644 index 0000000000..e867a57863 --- /dev/null +++ b/lib/extension/deviceConfigure.js @@ -0,0 +1,56 @@ +const settings = require('../util/settings'); +const logger = require('../util/logger'); +const zigbeeShepherdConverters = require('zigbee-shepherd-converters'); + +/** + * This extensions handles configuration of devices. + */ +class DeviceConfigure { + constructor(zigbee, mqtt, state, publishDeviceState) { + this.zigbee = zigbee; + this.configured = []; + } + + onZigbeeStarted() { + this.zigbee.getAllClients().forEach((device) => { + const mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId); + + if (mappedDevice) { + this.configure(device, mappedDevice); + } + }); + } + + onZigbeeMessage(message, device, mappedDevice) { + if (device && mappedDevice) { + this.configure(device, mappedDevice); + } + } + + configure(device, mappedDevice) { + const ieeeAddr = device.ieeeAddr; + + if (!this.configured.includes(ieeeAddr) && mappedDevice.configure) { + const friendlyName = settings.getDevice(ieeeAddr) ? settings.getDevice(ieeeAddr).friendly_name : 'unknown'; + + // Call configure function of this device. + mappedDevice.configure(ieeeAddr, this.zigbee.shepherd, this.zigbee.getCoordinator(), (ok, msg) => { + if (ok) { + logger.info(`Succesfully configured ${friendlyName} ${ieeeAddr}`); + } else { + logger.error(`Failed to configure ${friendlyName} ${ieeeAddr}`); + } + }); + + // Setup an OnAfIncomingMsg handler if needed. + if (mappedDevice.onAfIncomingMsg) { + mappedDevice.onAfIncomingMsg.forEach((ep) => this.zigbee.registerOnAfIncomingMsg(ieeeAddr, ep)); + } + + // Mark as configured + this.configured.push(ieeeAddr); + } + } +} + +module.exports = DeviceConfigure; diff --git a/lib/extension/devicePublish.js b/lib/extension/devicePublish.js index 0f486936d6..cfd721edd6 100644 --- a/lib/extension/devicePublish.js +++ b/lib/extension/devicePublish.js @@ -3,17 +3,17 @@ const settings = require('../util/settings'); const zigbeeShepherdConverters = require('zigbee-shepherd-converters'); const Queue = require('queue'); const logger = require('../util/logger'); + const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/.+/(set|get)$`); const postfixes = ['left', 'right', 'center', 'bottom_left', 'bottom_right', 'top_left', 'top_right']; +const maxDepth = 20; class DevicePublish { - constructor(zigbee, mqtt, state, mqttPublishDeviceState) { + constructor(zigbee, mqtt, state, publishDeviceState) { this.zigbee = zigbee; this.mqtt = mqtt; this.state = state; - - // TODO -> remove this; move to publish device state method to mqtt.js - this.mqttPublishDeviceState = mqttPublishDeviceState; + this.publishDeviceState = publishDeviceState; /** * Setup command queue. @@ -23,9 +23,10 @@ class DevicePublish { this.queue = new Queue(); this.queue.concurrency = 1; this.queue.autostart = true; + } + onMQTTConnected() { // Subscribe to topics. - const maxDepth = 20; for (let step = 1; step < maxDepth; step++) { const topic = `${settings.get().mqtt.base_topic}/${'+/'.repeat(step)}`; this.mqtt.subscribe(`${topic}set`); @@ -65,7 +66,7 @@ class DevicePublish { return {type: type, deviceID: deviceID, postfix: postfix}; } - handleMQTTMessage(topic, message) { + onMQTTMessage(topic, message) { topic = this.parseTopic(topic); if (!topic) { @@ -73,7 +74,7 @@ class DevicePublish { } // Map friendlyName to ieeeAddr if possible. - const ieeeAddr = settings.getIeeAddrByFriendlyName(topic.deviceID) || topic.deviceID; + const ieeeAddr = settings.getIeeeAddrByFriendlyName(topic.deviceID) || topic.deviceID; // Get device const device = this.zigbee.getDevice(ieeeAddr); @@ -133,7 +134,7 @@ class DevicePublish { const msg = {}; const _key = topic.postfix ? `state_${topic.postfix}` : 'state'; msg[_key] = key === 'brightness' ? 'ON' : json['state']; - this.mqttPublishDeviceState(device, msg, true); + this.publishDeviceState(device, msg, true); } queueCallback(); diff --git a/lib/extension/deviceReceive.js b/lib/extension/deviceReceive.js new file mode 100644 index 0000000000..26dc2915b8 --- /dev/null +++ b/lib/extension/deviceReceive.js @@ -0,0 +1,114 @@ +const settings = require('../util/settings'); +const logger = require('../util/logger'); + +const dontCacheProperties = ['click', 'action', 'button', 'button_left', 'button_right']; + +/** + * This extensions handles messages received from devices. + */ +class DeviceReceive { + constructor(zigbee, mqtt, state, publishDeviceState) { + this.zigbee = zigbee; + this.mqtt = mqtt; + this.state = state; + this.publishDeviceState = publishDeviceState; + } + + onZigbeeMessage(message, device, mappedDevice) { + if (message.type == 'devInterview' && !settings.getDevice(message.data)) { + logger.info('Connecting with device...'); + this.mqtt.log('pairing', 'connecting with device'); + } + + if (message.type == 'devIncoming') { + logger.info('Device incoming...'); + this.mqtt.log('pairing', 'device incoming'); + } + + if (!device) { + logger.warn('Message without device!'); + return; + } + + // Check if this is a new device. + if (!settings.getDevice(device.ieeeAddr)) { + logger.info(`New device with address ${device.ieeeAddr} connected!`); + settings.addDevice(device.ieeeAddr); + this.mqtt.log('device_connected', device.ieeeAddr); + } + + if (!mappedDevice) { + logger.warn(`Device with modelID '${device.modelId}' is not supported.`); + logger.warn(`Please see: https://github.com/Koenkk/zigbee2mqtt/wiki/How-to-support-new-devices`); + return; + } + + // After this point we cant handle message withoud cid or cmdId anymore. + if (!message.data || (!message.data.cid && !message.data.cmdId)) { + return; + } + + // Find a conveter for this message. + const cid = message.data.cid; + const cmdId = message.data.cmdId; + const converters = mappedDevice.fromZigbee.filter((c) => { + if (cid) { + return c.cid === cid && c.type === message.type; + } else if (cmdId) { + return c.cmd === cmdId; + } + + return false; + }); + + // Check if there is an available converter + if (!converters.length) { + if (cid) { + logger.warn( + `No converter available for '${mappedDevice.model}' with cid '${cid}', ` + + `type '${message.type}' and data '${JSON.stringify(message.data)}'` + ); + } else if (cmdId) { + logger.warn( + `No converter available for '${mappedDevice.model}' with cmd '${cmdId}' ` + + `and data '${JSON.stringify(message.data)}'` + ); + } + + logger.warn(`Please see: https://github.com/Koenkk/zigbee2mqtt/wiki/How-to-support-new-devices.`); + return; + } + + // Convert this Zigbee message to a MQTT message. + // Get payload for the message. + // - If a payload is returned publish it to the MQTT broker + // - If NO payload is returned do nothing. This is for non-standard behaviour + // for e.g. click switches where we need to count number of clicks and detect long presses. + converters.forEach((converter) => { + const publish = (payload) => { + // Don't cache messages with following properties: + let cache = true; + dontCacheProperties.forEach((property) => { + if (payload.hasOwnProperty(property)) { + cache = false; + } + }); + + // Add device linkquality. + if (message.hasOwnProperty('linkquality')) { + payload.linkquality = message.linkquality; + } + + this.publishDeviceState(device, payload, cache); + }; + + const payload = converter.convert(mappedDevice, message, publish, settings.getDevice(device.ieeeAddr)); + + if (payload) { + publish(payload); + } + }); + } +} + +module.exports = DeviceReceive; diff --git a/lib/extension/extensionTemplate.js b/lib/extension/extensionTemplate.js new file mode 100644 index 0000000000..b44f8114a1 --- /dev/null +++ b/lib/extension/extensionTemplate.js @@ -0,0 +1,55 @@ +/** + * This extensions is for documentation purposes only. + * It describes all methods that are called by the controller. + */ +class ExtensionTemplate { + /** + * Besides intializing variables, the constructor should do nothing! + * + * @param {Zigbee} zigbee Zigbee controller + * @param {MQTT} mqtt MQTT controller + * @param {State} state State controller + * @param {Function} publishDeviceState Method to publish device state to MQTT. + */ + constructor(zigbee, mqtt, state, publishDeviceState) { + this.zigbee = zigbee; + this.mqtt = mqtt; + this.state = state; + this.publishDeviceState = publishDeviceState; + } + + /** + * This method is called by the controller once Zigbee has been started. + */ + onZigbeeStarted() {} + + /** + * This method is called by the controller once connected to the MQTT server. + */ + onMQTTConnected() {} + + /** + * Is called when a Zigbee message from a device is received. + * @param {Object?} message The received message (can be null) + * @param {Object?} device The device of the message (can be null) + * @param {Object?} mappedDevice The mapped device (can be null) + */ + onZigbeeMessage(message, device, mappedDevice) {} + + /** + * Is called when a MQTT message is received + * @param {string} topic Topic on which the message was received + * @param {Object} message The received message + * @return {boolean} if the message was handled + */ + onMQTTMessage(topic, message) { + return false; + } + + /** + * Is called once the extension has to stop + */ + stop() {} +} + +module.exports = ExtensionTemplate; diff --git a/lib/homeassistant.js b/lib/extension/homeassistant.js similarity index 73% rename from lib/homeassistant.js rename to lib/extension/homeassistant.js index 6141a6b369..a997e239f2 100644 --- a/lib/homeassistant.js +++ b/lib/extension/homeassistant.js @@ -1,4 +1,6 @@ -const settings = require('./util/settings'); +const zigbeeShepherdConverters = require('zigbee-shepherd-converters'); +const settings = require('../util/settings'); +const logger = require('../util/logger'); const configurations = { // Binary sensor @@ -175,7 +177,7 @@ const configurations = { }, // Light - 'light_brightness_colortemp_xy': { + 'light_brightness_colortemp_colorxy': { type: 'light', object_id: 'light', discovery_payload: { @@ -186,7 +188,7 @@ const configurations = { command_topic: true, }, }, - 'light_brightness_xy': { + 'light_brightness_colorxy': { type: 'light', object_id: 'light', discovery_payload: { @@ -269,21 +271,21 @@ const mapping = { 'LED1537R6': [configurations.light_brightness_colortemp], 'LED1650R5': [configurations.light_brightness], 'LED1536G5': [configurations.light_brightness_colortemp], - '7299760PH': [configurations.light_brightness_xy], - '7146060PH': [configurations.light_brightness_colortemp_xy], + '7299760PH': [configurations.light_brightness_colorxy], + '7146060PH': [configurations.light_brightness_colortemp_colorxy], 'F7C033': [configurations.light_brightness], 'JTYJ-GD-01LM/BW': [configurations.binary_sensor_smoke], 'PLUG EDP RE:DY': [configurations.switch, configurations.sensor_power], 'CC2530.ROUTER': [configurations.binary_sensor_router], 'AA70155': [configurations.light_brightness_colortemp], - '4058075816718': [configurations.light_brightness_colortemp_xy], - 'AA69697': [configurations.light_brightness_colortemp_xy], + '4058075816718': [configurations.light_brightness_colortemp_colorxy], + 'AA69697': [configurations.light_brightness_colortemp_colorxy], 'HALIGHTDIMWWE27': [configurations.light_brightness], 'AB3257001NJ': [configurations.switch], '8718696449691': [configurations.light_brightness], - 'RB 185 C': [configurations.light_brightness_colortemp_xy], - '9290012573A': [configurations.light_brightness_colortemp_xy], - 'LED1624G9': [configurations.light_brightness_xy], + 'RB 185 C': [configurations.light_brightness_colortemp_colorxy], + '9290012573A': [configurations.light_brightness_colortemp_colorxy], + 'LED1624G9': [configurations.light_brightness_colorxy], '73742': [configurations.light_brightness_colortemp], '73740': [configurations.light_brightness_colortemp], '22670': [configurations.light_brightness], @@ -312,26 +314,26 @@ const mapping = { 'AA68199': [configurations.light_brightness_colortemp], 'QBKG11LM': [configurations.switch, configurations.sensor_power], 'QBKG12LM': [switchWithPostfix('left'), switchWithPostfix('right'), configurations.sensor_power], - 'K2RGBW01': [configurations.light_brightness_colortemp_xy], + 'K2RGBW01': [configurations.light_brightness_colortemp_colorxy], '9290011370': [configurations.light_brightness], 'DNCKATSW001': [configurations.switch], 'Z809A': [configurations.switch, configurations.sensor_power], 'NL08-0800': [configurations.light_brightness], - '915005106701': [configurations.light_brightness_colortemp_xy], + '915005106701': [configurations.light_brightness_colortemp_colorxy], 'AB32840': [configurations.light_brightness_colortemp], - '8718696485880': [configurations.light_brightness_colortemp_xy], + '8718696485880': [configurations.light_brightness_colortemp_colorxy], '8718696598283': [configurations.light_brightness_colortemp], '8718696695203': [configurations.light_brightness_colortemp], - '73693': [configurations.light_brightness_colortemp_xy], + '73693': [configurations.light_brightness_colortemp_colorxy], '324131092621': [configurations.sensor_action], '9290012607': [ configurations.binary_sensor_occupancy, configurations.sensor_temperature, configurations.sensor_illuminance, ], - 'GL-C-008': [configurations.light_brightness_colortemp_xy], + 'GL-C-008': [configurations.light_brightness_colortemp_colorxy], 'STSS-MULT-001': [configurations.binary_sensor_contact], 'E11-G23/E11-G33': [configurations.light_brightness], - 'AC03645': [configurations.light_brightness_colortemp_xy], + 'AC03645': [configurations.light_brightness_colortemp_colorxy], 'AC03641': [configurations.light_brightness], 'FB56+ZSW05HG1.2': [configurations.switch], '72922-A': [configurations.switch], @@ -345,103 +347,144 @@ const mapping = { 'BY 165': [configurations.light_brightness], 'ZLED-2709': [configurations.light_brightness], '8718696548738': [configurations.light_brightness_colortemp], - '4052899926110': [configurations.light_brightness_colortemp_xy], + '4052899926110': [configurations.light_brightness_colortemp_colorxy], 'Z01-CIA19NAE26': [configurations.light_brightness], - 'E11-N1EA': [configurations.light_brightness_colortemp_xy], + 'E11-N1EA': [configurations.light_brightness_colortemp_colorxy], '74283': [configurations.light_brightness], 'JTQJ-BF-01LM/BW': [configurations.binary_sensor_gas], '50045': [configurations.light_brightness], 'AV2010/22': [configurations.binary_sensor_occupancy], '3210-L': [configurations.switch], - '7299355PH': [configurations.light_brightness_xy], + '7299355PH': [configurations.light_brightness_colorxy], '45857GE': [configurations.light_brightness], 'A6121': [configurations.sensor_lock], '433714': [configurations.light_brightness], '3261030P7': [configurations.light_brightness_colortemp], 'DJT11LM': [configurations.sensor_action], 'E1603': [configurations.switch], - '7199960PH': [configurations.light_brightness_xy], + '7199960PH': [configurations.light_brightness_colorxy], '74696': [configurations.light_brightness], - 'AB35996': [configurations.light_brightness_colortemp_xy], + 'AB35996': [configurations.light_brightness_colortemp_colorxy], 'AB401130055': [configurations.light_brightness_colortemp], '74282': [configurations.light_brightness_colortemp], 'RS 128 T': [configurations.light_brightness_colortemp], '53170161': [configurations.light_brightness_colortemp], - '4058075036147': [configurations.light_brightness_colortemp_xy], + '4058075036147': [configurations.light_brightness_colortemp_colorxy], 'KS-SM001': [configurations.switch], 'MG-AUWS01': [switchWithPostfix('left'), switchWithPostfix('right')], - '9290002579A': [configurations.light_brightness_colortemp_xy], + '9290002579A': [configurations.light_brightness_colortemp_colorxy], '4256251-RZHAC': [configurations.switch, configurations.sensor_power], 'STS-PRS-251': [configurations.binary_sensor_presence], '4058075816794': [configurations.light_brightness_colortemp], '4052899926158': [configurations.light_brightness], - '4058075036185': [configurations.light_brightness_colortemp_xy], - '50049': [configurations.light_brightness_xy], - '915005733701': [configurations.light_brightness_colortemp_xy], - 'RB 285 C': [configurations.light_brightness_colortemp_xy], + '4058075036185': [configurations.light_brightness_colortemp_colorxy], + '50049': [configurations.light_brightness_colorxy], + '915005733701': [configurations.light_brightness_colortemp_colorxy], + 'RB 285 C': [configurations.light_brightness_colortemp_colorxy], '3216331P5': [configurations.light_brightness_colortemp], }; -// A map of all discoverd devices -const discovered = {}; +/** + * This extensions handles integration with HomeAssistant + */ +class HomeAssistant { + constructor(zigbee, mqtt, state, publishDeviceState) { + this.zigbee = zigbee; + this.mqtt = mqtt; + this.state = state; + this.publishDeviceState = publishDeviceState; -function discover(deviceID, model, mqtt, force) { - // Check if already discoverd and check if there are configs. - const discover = force || !discovered[deviceID]; - if (!discover || !mapping[model] || !settings.getDevice(deviceID)) { - return; - } + // A map of all discoverd devices + this.discovered = {}; - const friendlyName = settings.getDevice(deviceID).friendly_name; + if (!settings.get().advanced.cache_state) { + logger.warn('In order for HomeAssistant integration to work properly set `cache_state: true'); + } + } - mapping[model].forEach((config) => { - const topic = `${config.type}/${deviceID}/${config.object_id}/config`; - const payload = {...config.discovery_payload}; - payload.state_topic = `${settings.get().mqtt.base_topic}/${friendlyName}`; - payload.availability_topic = `${settings.get().mqtt.base_topic}/bridge/state`; + onMQTTConnected() { + this.mqtt.subscribe('hass/status'); - // Set unique names in cases this device produces multiple entities in homeassistant. - payload.name = mapping[model].length > 1 ? `${friendlyName}_${config.object_id}` : friendlyName; + // MQTT discovery of all paired devices on startup. + this.zigbee.getAllClients().forEach((device) => { + const mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId); + if (mappedDevice) { + this.discover(device.ieeeAddr, mappedDevice.model, true); + } + }); + } - // Only set unique_id when user did not set a friendly_name yet, - // see https://github.com/Koenkk/zigbee2mqtt/issues/138 - if (deviceID === friendlyName) { - payload.unique_id = `${deviceID}_${config.object_id}_${settings.get().mqtt.base_topic}`; + discover(ieeeAddr, model, force=false) { + // Check if already discoverd and check if there are configs. + const discover = force || !this.discovered[ieeeAddr]; + if (!discover || !mapping[model] || !settings.getDevice(ieeeAddr)) { + return; } - if (payload.command_topic) { - payload.command_topic = `${settings.get().mqtt.base_topic}/${friendlyName}/`; + const friendlyName = settings.getDevice(ieeeAddr).friendly_name; + + mapping[model].forEach((config) => { + const topic = `${config.type}/${ieeeAddr}/${config.object_id}/config`; + const payload = {...config.discovery_payload}; + payload.state_topic = `${settings.get().mqtt.base_topic}/${friendlyName}`; + payload.availability_topic = `${settings.get().mqtt.base_topic}/bridge/state`; + + // Set unique names in cases this device produces multiple entities in homeassistant. + payload.name = mapping[model].length > 1 ? `${friendlyName}_${config.object_id}` : friendlyName; + + // Only set unique_id when user did not set a friendly_name yet, + // see https://github.com/Koenkk/zigbee2mqtt/issues/138 + if (ieeeAddr === friendlyName) { + payload.unique_id = `${ieeeAddr}_${config.object_id}_${settings.get().mqtt.base_topic}`; + } + + if (payload.command_topic) { + payload.command_topic = `${settings.get().mqtt.base_topic}/${friendlyName}/`; - if (payload.command_topic_prefix) { - payload.command_topic += `${payload.command_topic_prefix}/`; + if (payload.command_topic_prefix) { + payload.command_topic += `${payload.command_topic_prefix}/`; + } + + payload.command_topic += 'set'; } - payload.command_topic += 'set'; + this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 0}, null, 'homeassistant'); + }); + + this.discovered[ieeeAddr] = true; + } + + onMQTTMessage(topic, message) { + if (!topic === 'hass/status') { + return false; } - mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 0}, null, 'homeassistant'); - }); + if (message.toString().toLowerCase() === 'online') { + const timer = setTimeout(() => { + // Publish all device states. + this.zigbee.getAllClients().forEach((device) => { + if (this.state.exists(device.ieeeAddr)) { + this.publishDeviceState(device, this.state.get(device.ieeeAddr), false); + } + }); - discovered[deviceID] = true; -} + clearTimeout(timer); + }, 20000); + } -function clear(deviceID, model, mqtt) { - // Check if there are configs. - if (!mapping[model]) { - return; + return true; } - mapping[model].forEach((config) => { - const topic = `${config.type}/${deviceID}/${config.object_id}/config`; - const payload = ''; - mqtt.publish(topic, payload, {retain: true, qos: 0}, null, 'homeassistant'); - }); + onZigbeeMessage(message, device, mappedDevice) { + if (device && mappedDevice) { + this.discover(device.ieeeAddr, mappedDevice.model); + } + } - discovered[deviceID] = false; + // Only for homeassistant.test.js + _getMapping() { + return mapping; + } } -module.exports = { - mapping: mapping, - discover: (deviceID, model, mqtt, force) => discover(deviceID, model, mqtt, force), - clear: (deviceID, model, mqtt) => clear(deviceID, model, mqtt), -}; +module.exports = HomeAssistant; diff --git a/lib/extension/markOnlineXiaomi.js b/lib/extension/markOnlineXiaomi.js new file mode 100644 index 0000000000..9f4a8e0df1 --- /dev/null +++ b/lib/extension/markOnlineXiaomi.js @@ -0,0 +1,29 @@ +const utils = require('../util/utils'); + +/** + * This extensions marks Xiaomi devices as online. + */ +class MarkOnlineXiaomi { + constructor(zigbee, mqtt, state, publishDeviceState) { + this.zigbee = zigbee; + } + + onZigbeeStarted() { + // Set all Xiaomi devices to be online, so shepherd won't try + // to query info from devices (which would fail because they go to sleep). + const devices = this.zigbee.getAllClients(); + devices.forEach((d) => { + if (utils.isXiaomiDevice(d)) { + const device = this.zigbee.shepherd.find(d.ieeeAddr, 1); + if (device) { + device.getDevice().update({ + status: 'online', + joinTime: Math.floor(Date.now() / 1000), + }); + } + } + }); + } +} + +module.exports = MarkOnlineXiaomi; diff --git a/lib/extension/networkMap.js b/lib/extension/networkMap.js index 7633489900..c7a25bdd32 100644 --- a/lib/extension/networkMap.js +++ b/lib/extension/networkMap.js @@ -1,16 +1,14 @@ - const settings = require('../util/settings'); const zigbeeShepherdConverters = require('zigbee-shepherd-converters'); class NetworkMap { - constructor(zigbee, mqtt, state) { + constructor(zigbee, mqtt, state, publishDeviceState) { this.zigbee = zigbee; this.mqtt = mqtt; this.state = state; // Subscribe to topic. this.topic = `${settings.get().mqtt.base_topic}/bridge/networkmap`; - this.mqtt.subscribe(this.topic); // Set supported formats this.supportedFormats = { @@ -19,7 +17,11 @@ class NetworkMap { }; } - handleMQTTMessage(topic, message) { + onMQTTConnected() { + this.mqtt.subscribe(this.topic); + } + + onMQTTMessage(topic, message) { message = message.toString(); if (topic === this.topic && this.supportedFormats.hasOwnProperty(message)) { diff --git a/lib/extension/routerPollXiaomi.js b/lib/extension/routerPollXiaomi.js index 370bb5738d..291bc7cc57 100644 --- a/lib/extension/routerPollXiaomi.js +++ b/lib/extension/routerPollXiaomi.js @@ -5,10 +5,12 @@ const interval = utils.secondsToMilliseconds(60); * This extensions polls Xiaomi Zigbee routers to keep them awake. */ class RouterPollXiaomi { - constructor(zigbee, mqtt, state) { + constructor(zigbee, mqtt, state, publishDeviceState) { this.zigbee = zigbee; this.timer = null; + } + onZigbeeStarted() { this.startTimer(); } diff --git a/lib/extension/softReset.js b/lib/extension/softReset.js index 763495d0ee..54005ce477 100644 --- a/lib/extension/softReset.js +++ b/lib/extension/softReset.js @@ -6,17 +6,14 @@ const utils = require('../util/utils'); * This extensions soft resets the ZNP after a certain timeout. */ class SoftReset { - constructor(zigbee, mqtt, state) { + constructor(zigbee, mqtt, state, publishDeviceState) { this.zigbee = zigbee; this.timer = null; this.timeout = utils.secondsToMilliseconds(settings.get().advanced.soft_reset_timeout); + } - if (this.timeout === 0) { - logger.debug(`Soft reset timeout disabled`); - } else { - logger.debug(`Soft reset timeout set to ${utils.millisecondsToSeconds(this.timeout)} seconds`); - } - + onZigbeeStarted() { + logger.debug(`Soft reset timeout set to ${utils.millisecondsToSeconds(this.timeout)} seconds`); this.resetTimer(); } @@ -58,7 +55,7 @@ class SoftReset { }); } - handleZigbeeMessage(message) { + onZigbeeMessage(message, device, mappedDevice) { this.resetTimer(); } } diff --git a/lib/mqtt.js b/lib/mqtt.js index 47133551e1..fb3a22f670 100644 --- a/lib/mqtt.js +++ b/lib/mqtt.js @@ -4,17 +4,18 @@ const settings = require('./util/settings'); class MQTT { constructor() { - this.handleConnect = this.handleConnect.bind(this); - this.handleMessage = this.handleMessage.bind(this); + this.onConnect = this.onConnect.bind(this); + this.onMessage = this.onMessage.bind(this); + this.messageHandler = null; } - connect(onMessage, subscriptions, callback) { + connect(messageHandler, callback) { const mqttSettings = settings.get().mqtt; logger.info(`Connecting to MQTT server at ${mqttSettings.server}`); const options = { will: { - topic: this.prefixTopic('bridge/state'), + topic: `${settings.get().mqtt.base_topic}/bridge/state`, payload: 'offline', retain: true, }, @@ -39,11 +40,11 @@ class MQTT { // Register callbacks. this.client.on('connect', () => { - this.handleConnect(); + this.onConnect(); callback(); }); - this.client.on('message', this.handleMessage); + this.client.on('message', this.onMessage); // Set timer at interval to check if connected to MQTT server. const interval = 10 * 1000; // seconds * 1000. @@ -53,8 +54,7 @@ class MQTT { } }, interval); - this.onMessage = onMessage; - this.subscriptions = subscriptions; + this.messageHandler = messageHandler; } disconnect() { @@ -67,24 +67,23 @@ class MQTT { }); } - handleConnect() { + onConnect() { logger.info('Connected to MQTT server'); this.publish('bridge/state', 'online', {retain: true, qos: 0}); - this.subscriptions.forEach((topic) => this.subscribe(topic)); } subscribe(topic) { this.client.subscribe(topic); } - handleMessage(topic, message) { - if (this.onMessage) { - this.onMessage(topic, message); + onMessage(topic, message) { + if (this.messageHandler) { + this.messageHandler(topic, message); } } - publish(topic, payload, options, callback, baseTopic) { - topic = this.prefixTopic(topic, baseTopic); + publish(topic, payload, options, callback, base=settings.get().mqtt.base_topic) { + topic = `${base}/${topic}`; if (!this.client || this.client.reconnecting) { logger.error(`Not connected to MQTT server!`); @@ -92,15 +91,10 @@ class MQTT { return; } - logger.info(`MQTT publish, topic: '${topic}', payload: '${payload}'`); + logger.info(`MQTT publish: topic '${topic}', payload '${payload}'`); this.client.publish(topic, payload, options, callback); } - prefixTopic(topic, baseTopic) { - baseTopic = baseTopic ? baseTopic : settings.get().mqtt.base_topic; - return `${baseTopic}/${topic}`; - } - log(type, message) { const payload = {type: type, message: message}; this.publish('bridge/log', JSON.stringify(payload), {retain: false}); diff --git a/lib/state.js b/lib/state.js index 153bccb2ba..abd04d3174 100644 --- a/lib/state.js +++ b/lib/state.js @@ -33,21 +33,21 @@ class State { fs.writeFileSync(this.file, json, 'utf8'); } - exists(ID) { - return this.state.hasOwnProperty(ID); + exists(ieeeAddr) { + return this.state.hasOwnProperty(ieeeAddr); } - get(ID) { - return this.state[ID]; + get(ieeeAddr) { + return this.state[ieeeAddr]; } - set(ID, state) { - this.state[ID] = state; + set(ieeeAddr, state) { + this.state[ieeeAddr] = state; } - remove(ID) { - if (this.exists(ID)) { - delete this.state[ID]; + remove(ieeeAddr) { + if (this.exists(ieeeAddr)) { + delete this.state[ieeeAddr]; } } } diff --git a/lib/util/settings.js b/lib/util/settings.js index b64fe398fd..6011f2bcc0 100644 --- a/lib/util/settings.js +++ b/lib/util/settings.js @@ -7,10 +7,27 @@ const path = require('path'); const defaults = { permit_join: false, + mqtt: { + include_device_information: false, + }, advanced: { log_directory: path.join(data.getPath(), 'log', '%TIMESTAMP%'), log_level: process.env.DEBUG ? 'debug' : 'info', soft_reset_timeout: 0, + pan_id: 0x1a62, + channel: 11, + baudrate: 115200, + rtscts: true, + + /** + * Home Assistant requires ALL attributes to be present in ALL MQTT messages send by the device. + * https://community.home-assistant.io/t/missing-value-with-mqtt-only-last-data-set-is-shown/47070/9 + * + * Therefore zigbee2mqtt BY DEFAULT caches all values and resend it with every message. + * advanced.cache_state in configuration.yaml allows to configure this. + * https://github.com/Koenkk/zigbee2mqtt/wiki/Configuration + */ + cache_state: true, }, }; @@ -29,40 +46,40 @@ function read() { return yaml.safeLoad(fs.readFileSync(file, 'utf8')); } -function addDevice(id) { +function addDevice(ieeeAddr) { if (!settings.devices) { settings.devices = {}; } - settings.devices[id] = {friendly_name: id, retain: false}; + settings.devices[ieeeAddr] = {friendly_name: ieeeAddr, retain: false}; writeRead(); } -function removeDevice(id) { - if (settings.devices && settings.devices[id]) { - delete settings.devices[id]; +function removeDevice(ieeeAddr) { + if (settings.devices && settings.devices[ieeeAddr]) { + delete settings.devices[ieeeAddr]; writeRead(); } } -function getIDByFriendlyName(friendlyName) { +function getIeeeAddrByFriendlyName(friendlyName) { if (!settings.devices) { return null; } - return Object.keys(settings.devices).find((id) => - settings.devices[id].friendly_name === friendlyName + return Object.keys(settings.devices).find((ieeeAddr) => + settings.devices[ieeeAddr].friendly_name === friendlyName ); } function changeFriendlyName(old, new_) { - const ID = getIDByFriendlyName(old); + const ieeeAddr = getIeeeAddrByFriendlyName(old); - if (!ID) { + if (!ieeeAddr) { return false; } - settings.devices[ID].friendly_name = new_; + settings.devices[ieeeAddr].friendly_name = new_; writeRead(); return true; } @@ -70,10 +87,11 @@ function changeFriendlyName(old, new_) { module.exports = { get: () => objectAssignDeep.noMutate(defaults, settings), write: () => write(), - getDevice: (id) => settings.devices ? settings.devices[id] : null, - addDevice: (id) => addDevice(id), - removeDevice: (id) => removeDevice(id), - getIDByFriendlyName: (friendlyName) => getIDByFriendlyName(friendlyName), + + getDevice: (ieeeAddr) => settings.devices ? settings.devices[ieeeAddr] : null, + addDevice: (ieeeAddr) => addDevice(ieeeAddr), + removeDevice: (ieeeAddr) => removeDevice(ieeeAddr), + + getIeeeAddrByFriendlyName: (friendlyName) => getIeeeAddrByFriendlyName(friendlyName), changeFriendlyName: (old, new_) => changeFriendlyName(old, new_), - getIeeAddrByFriendlyName: (friendlyName) => getIDByFriendlyName(friendlyName), }; diff --git a/lib/zigbee.js b/lib/zigbee.js index 08bcd664cf..42dafd6086 100644 --- a/lib/zigbee.js +++ b/lib/zigbee.js @@ -6,32 +6,31 @@ const zclPacket = require('zcl-packet'); const utils = require('./util/utils'); const advancedSettings = settings.get().advanced; - const shepherdSettings = { net: { - panId: advancedSettings && advancedSettings.pan_id ? advancedSettings.pan_id : 0x1a62, - channelList: [advancedSettings && advancedSettings.channel ? advancedSettings.channel : 11], + panId: advancedSettings.pan_id, + channelList: [advancedSettings.channel], }, dbPath: data.joinPath('database.db'), sp: { - baudRate: advancedSettings && advancedSettings.baudrate ? advancedSettings.baudrate : 115200, - rtscts: advancedSettings && (typeof(advancedSettings.rtscts) === 'boolean') ? advancedSettings.rtscts : true, + baudRate: advancedSettings.baudrate, + rtscts: advancedSettings.rtscts, }, }; logger.debug(`Using zigbee-shepherd with settings: '${JSON.stringify(shepherdSettings)}'`); class Zigbee { - constructor(onMessage) { - this.onMessage = onMessage; - this.handleReady = this.handleReady.bind(this); - this.handleMessage = this.handleMessage.bind(this); - this.handleError = this.handleError.bind(this); + constructor() { + this.onReady = this.onReady.bind(this); + this.onMessage = this.onMessage.bind(this); + this.onError = this.onError.bind(this); + this.messageHandler = null; } - start(callback) { + start(messageHandler, callback) { logger.info(`Starting zigbee-shepherd`); - + this.messageHandler = messageHandler; this.shepherd = new ZShepherd(settings.get().serial.port, shepherdSettings); this.shepherd.start((error) => { @@ -49,24 +48,24 @@ class Zigbee { ); callback(error); } else { - this._logStartupInfo(); + this.logStartupInfo(); callback(null); } }); - }, 60 * 1000); + }, utils.secondsToMilliseconds(60)); } else { - this._logStartupInfo(); + this.logStartupInfo(); callback(null); } }); // Register callbacks. - this.shepherd.on('ready', this.handleReady); - this.shepherd.on('ind', this.handleMessage); - this.shepherd.on('error', this.handleError); + this.shepherd.on('ready', this.onReady); + this.shepherd.on('ind', this.onMessage); + this.shepherd.on('error', this.onError); } - _logStartupInfo() { + logStartupInfo() { logger.info('zigbee-shepherd started'); logger.info(`Coordinator firmware version: '${this.shepherd.info().firmware.revision}'`); logger.debug(`zigbee-shepherd info: ${JSON.stringify(this.shepherd.info())}`); @@ -83,22 +82,7 @@ class Zigbee { }); } - handleReady() { - // Set all Xiaomi devices to be online, so shepherd won't try - // to query info from devices (which would fail because they go to sleep). - const devices = this.getAllClients(); - devices.forEach((d) => { - if (utils.isXiaomiDevice(d)) { - const device = this.shepherd.find(d.ieeeAddr, 1); - if (device) { - device.getDevice().update({ - status: 'online', - joinTime: Math.floor(Date.now() / 1000), - }); - } - } - }); - + onReady() { // Check if we have to turn off the led if (settings.get().serial.disable_led) { this.shepherd.controller.request('UTIL', 'ledControl', {ledid: 3, mode: 0}); @@ -107,7 +91,7 @@ class Zigbee { logger.info('zigbee-shepherd ready'); } - handleError(message) { + onError(message) { // This event may appear if zigbee-shepherd cannot decode bad packets (invalid checksum). logger.error(message); } @@ -134,14 +118,14 @@ class Zigbee { this.shepherd.remove(deviceID, (error) => { if (error) { logger.warn(`Failed to remove '${deviceID}', trying force remove...`); - this._forceRemove(deviceID, callback); + this.forceRemove(deviceID, callback); } else { callback(null); } }); } - _forceRemove(deviceID, callback) { + forceRemove(deviceID, callback) { const device = this.shepherd._findDevByAddr(deviceID); if (device) { @@ -167,9 +151,9 @@ class Zigbee { } } - handleMessage(message) { - if (this.onMessage) { - this.onMessage(message); + onMessage(message) { + if (this.messageHandler) { + this.messageHandler(message); } } @@ -187,7 +171,7 @@ class Zigbee { } publish(ieeAddr, cid, cmd, cmdType, zclData, cfg, ep, callback) { - const device = this._findDevice(ieeAddr, ep); + const device = this.findDevice(ieeAddr, ep); if (!device) { logger.error(`Zigbee cannot publish message to device because '${ieeAddr}' not known by zigbee-shepherd`); return; @@ -207,8 +191,6 @@ class Zigbee { } callback(error, rsp); - - // TODO: Send read reponse back to MQTT. }; if (cmdType === 'functional') { @@ -229,7 +211,7 @@ class Zigbee { } registerOnAfIncomingMsg(ieeeAddr, ep) { - const device = this._findDevice(ieeeAddr, ep); + const device = this.findDevice(ieeeAddr, ep); device.onAfIncomingMsg = (message) => { // Parse the message zclPacket.parse(message.data, message.clusterid, (error, zclData) => { @@ -238,12 +220,12 @@ class Zigbee { data: zclData, }; - this.handleMessage(message); + this.onMessage(message); }); }; } - _findDevice(deviceID, ep) { + findDevice(deviceID, ep) { // Find device in zigbee-shepherd let device = this.getDevice(deviceID); if (!device || !device.epList || !device.epList.length) { diff --git a/support/verify-homeassistant-mapping.js b/support/verify-homeassistant-mapping.js deleted file mode 100644 index 8bc564d36e..0000000000 --- a/support/verify-homeassistant-mapping.js +++ /dev/null @@ -1,13 +0,0 @@ -// Verify that there are homeassistant mappings for every device. -const devices = require('zigbee-shepherd-converters').devices; -const homeassistant = require('../lib/homeassistant'); - -let failed = false; -devices.forEach((d) => { - if (!homeassistant.mapping[d.model]) { - console.error(`Missing homeassistant mapping for '${d.model}'`); - failed = true; - } -}); - -process.exit(failed ? 1 : 0); \ No newline at end of file diff --git a/test/data.test.js b/test/data.test.js index 367153eb65..1b414ff48b 100644 --- a/test/data.test.js +++ b/test/data.test.js @@ -1,5 +1,3 @@ -/* global describe, it */ - const chai = require('chai'); const proxyquire = require('proxyquire').noPreserveCache(); const data = () => proxyquire('../lib/util/data.js', {}); diff --git a/test/devicePublish.test.js b/test/devicePublish.test.js index 0a1e9eed47..5c5ca5dc02 100644 --- a/test/devicePublish.test.js +++ b/test/devicePublish.test.js @@ -1,5 +1,3 @@ -/* global describe, it */ - const chai = require('chai'); const sinon = require('sinon'); const DevicePublish = require('../lib/extension/devicePublish'); diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js new file mode 100644 index 0000000000..b399ea7cc1 --- /dev/null +++ b/test/homeassistant.test.js @@ -0,0 +1,18 @@ +const devices = require('zigbee-shepherd-converters').devices; +const HomeassistantExtension = require('../lib/extension/homeassistant'); +const chai = require('chai'); +const homeassistant = new HomeassistantExtension(null, null, null, null); + +describe('HomeAssistant extension', () => { + it('Should have mapping for all devices supported by zigbee-shepherd-converters', () => { + const missing = []; + + devices.forEach((d) => { + if (!homeassistant._getMapping()[d.model]) { + missing.push(d.model); + } + }); + + chai.assert.strictEqual(missing.length, 0, `Missing HomeAssistant mapping for: ${missing.join(', ')}`); + }); +});