diff --git a/DeviceManager.js b/DeviceManager.js index 5a380d7..5b77451 100644 --- a/DeviceManager.js +++ b/DeviceManager.js @@ -25,16 +25,16 @@ class DeviceManager { return device; if (this.deviceNames && this.deviceNames.has(device)) return this.deviceNames.get(device); - if (this.deviceTopics && this.deviceTopics.has(device)) - return this.deviceTopics.get(device); + if (this.deviceTopics && this.deviceTopics.has(normalize(device))) + return this.deviceTopics.get(normalize(device)); } else if (typeof device === 'object') { if (device.id) return device.id; if (device.name) { if (this.deviceNames && this.deviceNames.has(device.name)) return this.deviceNames.get(device.name); - if (this.deviceTopics && this.deviceTopics.has(device.name)) - return this.deviceTopics.get(device.name); + if (this.deviceTopics && this.deviceTopics.has(normalize(device.name))) + return this.deviceTopics.get(normalize(device.name)); } } } diff --git a/EventHandler.js b/EventHandler.js index 80254ca..67e7cd3 100644 --- a/EventHandler.js +++ b/EventHandler.js @@ -20,6 +20,7 @@ class EventHandler { } } + unsubscribe(callback) { return this.remove(callback); } remove(callback) { this._listeners = this._listeners.filter(c => c !== callback); } diff --git a/README.md b/README.md index 5b006ff..eee9b7f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Furthermore it provides an interface to read the full system state & and allows ## What can it be used for? Some of the many possibilities: -- Integrate with other home automation systems: [OpenHab](https://www.openhab.org/), [Home Assistant](https://www.home-assistant.io/), [Domiticz](http://www.domoticz.com/), etc. +- Integrate with other home automation systems: [OpenHab](https://www.openhab.org/), [Home Assistant](https://www.home-assistant.io/), [Domoticz](http://www.domoticz.com/), etc. - Create custom dashboards: [TileBoard](https://community.home-assistant.io/t/tileboard-new-dashboard-for-homeassistant/57173), [HABPanel](https://www.openhab.org/docs/configuration/habpanel.html), [Node RED Dashboard](https://flows.nodered.org/node/node-red-dashboard), etc. - Create advanced flows and logic: [Node RED](https://nodered.org/), etc. - Use native mobile apps (3rd party): [MQTT Dash](https://play.google.com/store/apps/details?id=net.routix.mqttdash), etc. diff --git a/TODO b/TODO index ece456f..d75e53b 100644 --- a/TODO +++ b/TODO @@ -5,37 +5,22 @@ BUGS: - [BUG] client/hub registration? Settings: -! HASS settings (enable disable discovery + topic root) -! seperate root topics (homie, HASS, system state, custom, etc.) -! Make class constants configurable -! Split hiding of controls & applying default (protocol) settings -! settings default vs disabled controls -! settings restore app defaults button +! [CHECK] initial settings @ clean install +! Clear command & system state topics when changeing topic ! Check 'App' initilaized (prevent settings crash) ! Update Instructions -! [CHECK] Load deviceId @ first load -! [CHECK] initial settings @ clean install -! Allow all chars for DeviceId, normalize for topics (based on setting) -! Inject deviceId (normalized) in topics -! [CHECK] broadcast on settings changes (if needed) ! [CHECK] en.json translations -- optional normalization for custom protocol -- system info topic configurable - Enable/disable all devices - display broadcast progress (loader) -- Birth & Last will topics + messages -- enable/disable set command handler - Disable device capabilities - Show number of messages per device - Display number of messages in queue (fill 100% === number of devices) - Seperate call for device enable/disable HASS: -! Enable/disable (broadcast / clear messages) +! Clear all HASS messages (Register all topics &clear @ settings topic change) ! Color temperature (read & write) ! Update correct HSV values when setting one of the values (color topic) -! Implement full reload @ settings change -! Clear all HASS messages - Set numeric values? input_number: https://www.home-assistant.io/components/input_number/? - Remaining device types (Alarm control panels, Binary sensors, Cameras, Covers, Fans, HVACs, Lights, Locks, Sensors, Switches, Vacuums) - thermostat modes @@ -52,6 +37,7 @@ Homie: - Button-press & non-retained messages? App: +! Adjust documentation for HA Discovery & changed settings (readme) - [Check] shutdown procedure - [Check] Refresh devices on device/zone changes + Broadcast - Uninstall app => unsubscribe from topics on MQTT lient @@ -63,7 +49,7 @@ Homey: Discovery (auto discover external devices): - Implement Homie discovery -- Implement HASS Discovery +- Implement HA discovery - Create virtual devices from MQTT discovery Commands: @@ -78,13 +64,13 @@ Commands: System info: - Publish system info on seperate topics (i.s.o. json) - +- (All) Prometheus functionality ======= DONE ======= - Normalize device name - Fix getDeviceName for homey 1.5.13 -- MQTT discovery (see OpenHAB / Domiticz) +- MQTT discovery (see OpenHAB / Domoticz) - Implement Homie convention (Homey v1.5.13) - Homie Convention for Homey v2.0 - Normalization BUG @@ -134,3 +120,21 @@ x test internal MQTT client ('socket hang up' fix? duplicate mem usage, config & - HASS enable device => send discover config - HASS Keep track of device topics & remove messages from queue when disabling device - Send empty messages to clear devices +- [BUG] set command not working when deviceId or topic root is empty +- HASS settings (enable disable discovery + topic root) +- seperate root topics (homie, HASS, system state, custom, etc.) +- Make class constants configurable +- Re-factor settings +- settings restore app defaults button +- [CHECK] Load deviceId @ first load +- Allow all chars for DeviceId, normalize for topics (based on setting) +- Inject deviceId (normalized) in topics +- Skip reloading when changing birth & last will messages +- HASS: Implement full reload @ settings change +- optional normalization for custom protocol +- system info topic configurable +- Birth & Last will topics + messages +- enable/disable set command handler +- HASS: Enable/disable +x Settings: Split hiding of devices & applying default (protocol) settings +- [CHECK] broadcast on settings changes (if needed) \ No newline at end of file diff --git a/app.js b/app.js index 864fd72..8243d55 100644 --- a/app.js +++ b/app.js @@ -5,7 +5,6 @@ if (DEBUG) { require('inspector').open(9229, '0.0.0.0', false); } -const normalize = require('./normalize'); const Homey = require('homey'); const { HomeyAPI } = require('athom-api'); const MQTTClient = require('./mqtt/MQTTClient'); @@ -26,9 +25,9 @@ const HomeAssistantDispatcher = require("./dispatchers/HomeAssistantDispatcher.j const CommandHandler = require("./commands/CommandHandler.js"); // Birth & Last will -const BIRTH_TOPIC = '{deviceId}/hub/status'; // NOTE: Empty to ommit +const BIRTH_TOPIC = '{deviceId}/hub/status'; const BIRTH_MESSAGE = 'online'; -const WILL_TOPIC = '{deviceId}/hub/status'; // NOTE: Empty to ommit +const WILL_TOPIC = '{deviceId}/hub/status'; const WILL_MESSAGE = 'offline'; class MQTTHub extends Homey.App { @@ -40,6 +39,7 @@ class MQTTHub extends Homey.App { Homey.on('unload', () => this.uninstall()); this.settings = Homey.ManagerSettings.get('settings') || {}; + this.birthWill = this.settings.birthWill !== false; Log.debug(this.settings, false, false); @@ -54,7 +54,7 @@ class MQTTHub extends Homey.App { } Log.debug("Update settings"); - this.updateSettings(); + this.initSettings(); Log.debug("Initialize MQTT Client & Message queue"); this.mqttClient = new MQTTClient(); @@ -82,13 +82,13 @@ class MQTTHub extends Homey.App { } } - updateSettings() { + initSettings() { const systemName = this.system.name || 'Homey'; if (this.settings.deviceId === undefined || this.settings.systemName !== systemName || this.settings.topicRoot) { // Backwards compatibility if (this.settings.topicRoot && !this.settings.homieTopic) { - this.settings.homieTopic = this.settings.topicRoot; + this.settings.homieTopic = this.settings.topicRoot + '/' + (this.settings.deviceId || systemName); delete this.settings.topicRoot; } @@ -100,130 +100,156 @@ class MQTTHub extends Homey.App { } } + /** + * Start Hub + * */ async start() { + if (this._running) return; + this._running = true; + try { - Log.info('app start'); + Log.info('start Hub'); await this.mqttClient.connect(); this._sendBirthMessage(); - this._startCommands(); - this._startBroadcasters(); - - const protocol = this.settings.protocol || 'homie3'; - if (this.protocol !== protocol) { - Log.info("Changing protocol from '" + this.protocol + "' to '" + protocol + "'"); - this._stopCommunicationProtocol(this.protocol); - await this._startCommunicationProtocol(protocol); - } + await this.run(); + Log.info('app running: true'); } catch (e) { - Log.error('Failed to start app'); + Log.error('Failed to start Hub'); Log.error(e); } } + /** + * Stop Hub + * */ stop() { + if (!this._running) return; + this._running = false; + + Log.info('stop Hub'); this._sendLastWillMessage(); - Log.info('app stop'); this.mqttClient.disconnect(); - this._stopCommands(); - this._stopBroadcasters(); + this._stopCommunicationProtocol(); + this._stopBroadcasters(); + this._stopCommands(); delete this.protocol; - // TODO: Unsubscribe all topics + this.messageQueue.stop(); + this.messageQueue.clear(); Log.info('app running: false'); } - - async _startCommunicationProtocol(protocol) { - this.protocol = protocol || this.protocol; - Log.info('start communication protocol: ' + this.protocol); - // NOTE: All communication is based on the (configurable) Homie Convention... - this.homieDispatcher = new HomieDispatcher(this); - - // Enable Home Assistant Discovery - // TODO: Make HomeAssistantDispatcher configurable - this.homeAssistantDispatcher = new HomeAssistantDispatcher(this); - await this.homeAssistantDispatcher.register(); - - // Register all devices & dispatch current state - this.homieDispatcher.register(); + /** + * Load configuration & run + * Note: Called from start & settings changed + * */ + async run() { + + this._initProtocol(); + + await this._startCommands(); + await this._startBroadcasters(); + await this._startHomeAssistantDiscovery(); + await this._startCommunicationProtocol(); } - _stopCommunicationProtocol(protocol) { - protocol = protocol || this.protocol; - - if (protocol) { - - Log.info('stop communication protocol: ' + this.protocol); - - // NOTE: All communication is based on the (configurable) Homie Convention... - if (this.homieDispatcher) { - this.homieDispatcher.destroy(); - delete this.homieDispatcher; - } - - // Disable Home Assistant Discovery - if (this.homeAssistantDispatcher) { - this.homeAssistantDispatcher.destroy(); - delete this.homeAssistantDispatcher; - } + _initProtocol() { + this.protocol = this.settings.protocol || 'homie3'; + Log.info('Initialize communication protocol: ' + this.protocol); + + switch (this.protocol) { + case "custom": + this.settings.homieTopic = this.settings.customTopic; + break; + case "homie3": + default: + this.settings.topicIncludeClass = false; + this.settings.topicIncludeZone = false; + this.settings.normalize = true; + this.settings.percentageScale = "int"; + this.settings.colorFormat = "hsv"; + this.settings.broadcastDevices = true; + break; } + + // NOTE: All communication is based on the (configurable) Homie Convention... + Log.info("Initialize HomieDispatcher"); + this.homieDispatcher = this.homieDispatcher || new HomieDispatcher(this); + this.homieDispatcher.applySettings(this.settings); } - _startCommands() { - this._stopCommands(); - this.commandHandler = new CommandHandler(this); // TODO: Refactor command handler with the abillity to register commands + async _startCommands() { + if (this.settings.commands) { + Log.info("start commands"); + // TODO: Refactor command handler with the abillity to register commands + this.commandHandler = this.commandHandler || new CommandHandler(this); + await this.commandHandler.init(this.settings); + } else { + this._stopCommands(); + } } _stopCommands() { if (this.commandHandler) { + Log.info("stop command handler"); this.commandHandler.destroy(); delete this.commandHandler; } } - _startBroadcasters() { - Log.info("start broadcasters"); - if (this.homieDispatcher) { - const broadcast = this.settings.broadcastDevices !== false; - Log.info("homie dispatcher broadcast: " + broadcast); - this.homieDispatcher.broadcast = broadcast; - } - - if (this.homeAssistantDispatcher) { - const broadcast = this.settings.broadcastDevices !== false; - Log.info("Home Assistant dispatcher broadcast: " + broadcast); - this.homeAssistantDispatcher.broadcast = broadcast; - } - - if (!this.systemStateDispatcher && this.settings.broadcastSystemState) { - Log.info("start system dispatcher"); - this.systemStateDispatcher = new SystemStateDispatcher(this); + async _startBroadcasters() { + if (this.settings.broadcastSystemState) { + Log.info("start system state broadcaster"); + this.systemStateDispatcher = this.systemStateDispatcher || new SystemStateDispatcher(this); + await this.systemStateDispatcher.init(this.settings); + } else { + this._stopBroadcasters(); } } - _stopBroadcasters() { - Log.info("stop broadcasters"); - if (this.homieDispatcher) { - Log.info("stop homie dispatcher"); - this.homieDispatcher.broadcast = false; - } - - if (this.homeAssistantDispatcher) { - Log.info("stop Home Assistant dispatcher"); - this.systemStateDispatcher.broadcast = false; - } - if (this.systemStateDispatcher) { - Log.info("stop system dispatcher"); + Log.info("stop system state broadcaster"); this.systemStateDispatcher.destroy() .then(() => Log.info("Failed to destroy SystemState Dispatcher")) .catch(error => Log.error(error)); delete this.systemStateDispatcher; } } + + async _startHomeAssistantDiscovery() { + if (this.settings.hass) { + Log.info("start Home Assistant Discovery"); + this.homeAssistantDispatcher = this.homeAssistantDispatcher || new HomeAssistantDispatcher(this); + await this.homeAssistantDispatcher.init(this.settings, this.deviceChanges); + } else { + Log.info("stop Home Assistant Discovery"); + this._stopHomeAssistantDiscovery(); + } + } + _stopHomeAssistantDiscovery() { + if (this.homeAssistantDispatcher) { + Log.info("stop Home Assistant Discovery"); + this.homeAssistantDispatcher.destroy(); + delete this.homeAssistantDispatcher; + } + } + + async _startCommunicationProtocol() { + // Register all devices & dispatch current state + Log.info('Start communication protocol: ' + this.protocol); + await this.homieDispatcher.init(this.settings, this.deviceChanges); + } + _stopCommunicationProtocol() { + // NOTE: All communication is based on the (configurable) Homie Convention... + if (this.homieDispatcher) { + Log.info('stop communication protocol: ' + this.protocol); + this.homieDispatcher.destroy(); + delete this.homieDispatcher; + } + } async _getSystemInfo() { Log.info("get system info"); @@ -263,7 +289,8 @@ class MQTTHub extends Homey.App { } isRunning() { - return this.mqttClient && this.mqttClient.isRegistered() && !this.pause; + return this._running; + //return this.mqttClient && this.mqttClient.isRegistered() && !this.pause; } setRunning(running) { @@ -299,30 +326,36 @@ class MQTTHub extends Homey.App { this.settings = Homey.ManagerSettings.get('settings') || {}; Log.debug(this.settings); + // birth & last will + if (this.settings.birthWill) { + if (this.birthWill !== this.settings.birthWill) { + this._sendBirthMessage(); + } + } else { + if (this.birthWill) { + this._clearBirthWill(); + } + } + this.birthWill = this.settings.birthWill; + // devices - let deviceChanges = null; if (this.deviceManager) { - deviceChanges = this.deviceManager.computeChanges(this.settings.devices); + this.deviceChanges = this.deviceManager.computeChanges(this.settings.devices); this.deviceManager.setEnabledDevices(this.settings.devices); } - if (this.homieDispatcher) { - this.homieDispatcher.updateSettings(this.settings, deviceChanges); - } - - if (this.homeAssistantDispatcher) { - this.homeAssistantDispatcher.updateSettings(this.settings, deviceChanges); - } + await this.run(); // clean-up all messages for disabled devices - for (let deviceId of deviceChanges.disabled) { + for (let deviceId of this.deviceChanges.disabled) { if (typeof deviceId === 'string') { this.topicsRegistry.remove(deviceId, true); } } - // protocol, broadcasts - await this.start(); // NOTE: Changes are detected in the start method(s) + // clean-up + delete this.deviceChanges; + } catch (e) { Log.error("Failed to update settings"); Log.error(e); @@ -330,19 +363,25 @@ class MQTTHub extends Homey.App { } _sendBirthMessage() { - if (this.mqttClient && BIRTH_TOPIC && BIRTH_MESSAGE) { - const deviceId = this.settings && this.settings.deviceId ? this.settings.deviceId : 'Homey'; - const topic = BIRTH_TOPIC.replace('{deviceId}', deviceId); - this.mqttClient.publish(new Message(topic, BIRTH_MESSAGE, 1, true)); + if (this.mqttClient && this.settings.birthWill !== false) { + const topic = this.settings.birthTopic || BIRTH_TOPIC.replace('{deviceId}', this.settings.deviceId); + const msg = this.settings.birthMessage || BIRTH_MESSAGE; + this.mqttClient.publish(new Message(topic, msg, 1, true)); } } _sendLastWillMessage() { - if (this.mqttClient && WILL_TOPIC && WILL_MESSAGE) { - const deviceId = this.settings && this.settings.deviceId ? this.settings.deviceId : 'Homey'; - const topic = WILL_TOPIC.replace('{deviceId}', deviceId); - this.mqttClient.publish(new Message(topic, WILL_MESSAGE, 1, true)); + if (this.mqttClient && this.settings.birthWill !== false) { + const topic = this.settings.willTopic || WILL_TOPIC.replace('{deviceId}', this.settings.deviceId); + const msg = this.settings.willMessage || WILL_MESSAGE; + this.mqttClient.publish(new Message(topic, msg, 1, true)); } } + _clearBirthWill() { + const birthTopic = this.settings.birthTopic || BIRTH_TOPIC.replace('{deviceId}', this.settings.deviceId); + const willTopic = this.settings.willTopic || BIRTH_TOPIC.replace('{deviceId}', this.settings.deviceId); + this.mqttClient.publish(new Message(birthTopic, null, 1, true)); + this.mqttClient.publish(new Message(willTopic, null, 1, true)); + } uninstall() { try { diff --git a/commands/CommandHandler.js b/commands/CommandHandler.js index e456e65..d2dd79b 100644 --- a/commands/CommandHandler.js +++ b/commands/CommandHandler.js @@ -9,26 +9,32 @@ const TOPIC = 'homey/$command'; // //$command * */ class CommandHandler { - constructor({ api, mqttClient, deviceManager, settings }) { - + constructor({ api, mqttClient, deviceManager }) { this.api = api; this.mqttClient = mqttClient; this.deviceManager = deviceManager; - this._init() - .then(() => Log.info("CommandHandler initialized")) - .catch(error => Log.error(error)); + if (this.mqttClient) { + this._clientCallback = this._onMessage.bind(this); + this.mqttClient.onMessage.subscribe(this._clientCallback); + } } - async _init() { + async init(settings) { + if (!this.mqttClient) return; try { - if (this.mqttClient) { - Log.info("Starting set command handler"); - this._clientCallback = this._onMessage.bind(this); - this.mqttClient.onMessage.subscribe(this._clientCallback); - - await this.mqttClient.subscribe(TOPIC); + Log.info("Initializing set command handler"); + const topic = (settings.commandTopic || TOPIC).replace('{deviceId}', settings.deviceId); + if (this.topic !== topic) { + if (this.topic) { + await this.mqttClient.unsubscribe(this.topic); + } + this.topic = topic; + if (this.topic) { + await this.mqttClient.subscribe(this.topic); + } } + Log.info("CommandHandler initialized"); } catch (e) { Log.error('Failed to initialize CommandHandler'); Log.debug(e); @@ -37,7 +43,7 @@ class CommandHandler { async _onMessage(topic, message) { - if (topic !== TOPIC) return; + if (topic !== this.topic) return; try { Log.debug('SetCommendHandler.onMessage:'); diff --git a/dispatchers/HomeAssistantDispatcher.js b/dispatchers/HomeAssistantDispatcher.js index 25a20de..10db5e3 100644 --- a/dispatchers/HomeAssistantDispatcher.js +++ b/dispatchers/HomeAssistantDispatcher.js @@ -271,7 +271,7 @@ const coverClasses = new Set([ // NOTE: Make configurable const DEFAULT_DEVICE_ID = 'homey'; -const DEFAULT_TOPIC_ROOT = 'homeassistant'; +const DEFAULT_TOPIC = 'homeassistant'; const STATUS_TOPIC = 'hass/status'; const STATUS_ONLINE = 'online'; const STATUS_OFFLINE = 'offline'; @@ -281,14 +281,7 @@ const STATUS_OFFLINE = 'offline'; * */ class HomeAssistantDispatcher { - get _topicRoot() { - return this.settings && this.settings.haRoot ? this.settings.haRoot : DEFAULT_TOPIC_ROOT; // TODO: add haRoot property to settings - } - get _deviceId() { - return this.settings && this.settings.deviceId ? this.settings.deviceId : DEFAULT_DEVICE_ID; - } - - constructor({ api, mqttClient, deviceManager, system, settings, homieDispatcher, messageQueue, topicsRegistry }) { + constructor({ api, mqttClient, deviceManager, system, homieDispatcher, messageQueue, topicsRegistry }) { this.api = api; this.mqttClient = mqttClient; this.deviceManager = deviceManager; @@ -298,50 +291,115 @@ class HomeAssistantDispatcher { this.topicsRegistry = topicsRegistry; this._registered = new Set(); - this._deviceTopics = new Map(); + this._topics = new Set(); // TODO: Register all topics &clear @ settings topic change + + if (mqttClient) { + this._clientCallback = this._onMessage.bind(this); + this.mqttClient.onMessage.subscribe(this._clientCallback); + } + } - this.updateSettings(settings); + breakingChanges(settings) { + const hash = JSON.stringify({ + hassTopic: settings.hassTopic, + normalize: settings.normalize, + deviceId: settings.deviceId + }); + const changed = this._settingsHash !== hash; + this._settingsHash = hash; + return changed; } - async register() { + async init(settings, deviceChanges) { + if (!this.mqttClient) return; + try { - await this._init(); + this.enabled = settings.hass; + if (!this.enabled) { + // TODO: CLear all topics + //await this.clearTopics(); + return; + } + + this.normalize = settings.normalize; + this.deviceId = normalize(settings.deviceId || DEFAULT_DEVICE_ID); + + await this.registerHassStatus(settings); + + let topic = (settings.hassTopic || DEFAULT_TOPIC).replace('{deviceId}', settings.deviceId); + if (settings.normalize) { + topic = normalize(topic); + } + + if (this.breakingChanges(settings)) { + + if (this.topic !== topic) { + //await this.clearTopics();// TODO: clear all previous topics + } + this.topic = topic; + + if (this.enabled) { + // NOTE: If the client is already connected, the 'connect' event won't be fired. + // Therefore we mannually dispatch the state if already connected/registered. + if (this.mqttClient.isRegistered()) { + this.dispatchState(); + } else { + this.mqttClient.onRegistered.subscribe(() => this.dispatchState(), true); + } + } + } else if (deviceChanges) { // update changed devices only + Log.info("Update settings for changed devices only"); + if (this.enabled) { + for (let deviceId of deviceChanges.enabled) { + if (typeof deviceId === 'string') { + this.enableDevice(deviceId); + } + } + } + for (let deviceId of deviceChanges.disabled) { + if (typeof deviceId === 'string') { + this.disableDevice(deviceId); + } + } + } + Log.info("HomeAssistant Dispatcher initialized"); } catch (e) { Log.error("Failed to initialize HomeAssistantDispatcher"); Log.error(e); } } - - async _init() { - - // subscribe the HASS Birth & Last will messages - await this.mqttClient.subscribe(STATUS_TOPIC); - this._clientCallback = this._onMessage.bind(this); - this.mqttClient.onMessage.subscribe(this._clientCallback); - - // NOTE: If the client is already connected, the 'connect' event won't be fired. - // Therefore we mannually dispatch the state if already connected/registered. - if (this.mqttClient.isRegistered()) - this.dispatchState(); - else - this.mqttClient.onRegistered.subscribe(() => this.dispatchState(), true); + + async registerHassStatus(settings) { + const statusTopic = settings.hassStatusTopic || STATUS_TOPIC; + this.hassOnlineMessage = settings.hassOnlineMessage || STATUS_ONLINE; + this.hassOfflineMessage = settings.hassOfflineMessage || STATUS_OFFLINE; + if (this.statusTopic !== statusTopic) { + if (this.statusTopic) { + await this.mqttClient.unsubscribe(this.statusTopic); + } + this.statusTopic = statusTopic; + if (this.statusTopic) { + await this.mqttClient.subscribe(this.statusTopic); + } + } } async _onMessage(topic, message) { - if (topic !== STATUS_TOPIC) return; + if (topic !== this.hassStatusTopic) return; - Log.info("Received HASS Birth message: " + message); + Log.info("Received HomeAssistant status message: " + message); try { - if (message === STATUS_ONLINE && this.mqttClient.isRegistered()) { + if (message === this.hassOnlineMessage && this.mqttClient.isRegistered()) { Log.info('Dispatch state'); this.dispatchState(); this.homieDispatcher.dispatchState(); } + // Note: Hass offline message is discarded } catch (e) { - Log.info('Error handling HASS status message'); + Log.info('Error handling HomeAssistant status message'); Log.debug(topic); Log.debug(message); Log.error(e); @@ -353,33 +411,6 @@ class HomeAssistantDispatcher { this.registerDevices(); } - updateSettings(settings, deviceChanges) { - settings = settings || {}; - const current = this.settings ? JSON.stringify(this.settings) : null; - this.settings = this.settings || {}; - - // TODO: Topic from settings - //this.settings.hassTopic = settings.hassTopic === undefined ? DEFAULT_TOPIC_ROOT : settings.hassTopic; - this.settings.deviceId = normalize(settings.deviceId || this.system.name || DEFAULT_DEVICE_ID); - - // Breaking changes? => Start a new HomieDevice (& destroy current) - if (current && current !== JSON.stringify(this.settings)) { - // TODO: Implement - } else if (deviceChanges) { // update changed devices only - Log.info("Update settings for changed devices only"); - for (let deviceId of deviceChanges.enabled) { - if (typeof deviceId === 'string') { - this.enableDevice(deviceId); - } - } - for (let deviceId of deviceChanges.disabled) { - if (typeof deviceId === 'string') { - this.disableDevice(deviceId); - } - } - } - } - // Get all devices and add them registerDevices() { Log.info("register devices"); @@ -416,7 +447,7 @@ class HomeAssistantDispatcher { } this._registered.add(device.id); - Log.info("Home Assistant discover: " + device.name); + Log.info("HASS discover: " + device.name); const remainingCapabilities = this._registerDeviceClass(device); this._registerCapabilities(device, remainingCapabilities); @@ -500,7 +531,10 @@ class HomeAssistantDispatcher { // TODO: light_mode // TODO: RGB color setting - const topic = [this._topicRoot, type, normalize(device.name), 'config'].join('/'); + let topic = [...this.topic.split('/'), type, device.name, 'config'].filter(x => x).join('/'); + if (this.normalize) { + topic = normalize(topic); + } this._registerConfig(device, type, topic, payload); return ['onoff', 'dim', 'light_hue', 'light_saturation', 'light_temperature', 'color', 'rgb', 'hsv']; @@ -547,7 +581,10 @@ class HomeAssistantDispatcher { payload.mode_state_template = "{% set values = { 'schedule':'auto', 'manual':'heat', 'notused':'cool', 'off':'off'} %}{{ values[value] if value in values.keys() else 'off' }}"; } - const topic = [this._topicRoot, type, normalize(device.name), 'config'].join('/'); + let topic = [...this.topic.split('/'), type, device.name, 'config'].filter(x => x).join('/'); + if (this.normalize) { + topic = normalize(topic); + } this._registerConfig(device, type, topic, payload); return ['onoff', 'measure-temperature', 'target-temperature', 'custom-thermostat-mode']; @@ -576,8 +613,6 @@ class HomeAssistantDispatcher { return undefined; } - const deviceId = normalize(device.name); - const capabilityId = normalize(capability.id); const capabilityTitle = capability.title && typeof capability.title === 'object' ? capability.title['en'] : capability.title; const capabilityName = capabilityTitle || capability.desc || capability.id; const type = config.type; @@ -607,7 +642,10 @@ class HomeAssistantDispatcher { //} // final payload = above payload with added & overidden values from config - const topic = [this._topicRoot, type, deviceId, capabilityId, 'config'].join('/'); + let topic = [...this.topic.split('/'), type, device.name, capability.id, 'config'].filter(x => x).join('/'); + if (this.normalize) { + topic = normalize(topic); + } this._registerConfig(device, type, topic, { ...payload, ...config.payload }); } @@ -679,7 +717,7 @@ class HomeAssistantDispatcher { // Include device info config.device = { - identifiers: `${this._deviceId}_${device.id}`, + identifiers: `${this.deviceId}_${device.id}`, name: device.name }; @@ -728,6 +766,9 @@ class HomeAssistantDispatcher { } destroy() { + if (this.mqttClient) { + this.mqttClient.onMessage.unsubscribe(this._clientCallback); + } Log.info('Destroy HomeAssistantDispatcher'); } } diff --git a/dispatchers/HomieDispatcher.js b/dispatchers/HomieDispatcher.js index ed82460..8197c46 100644 --- a/dispatchers/HomieDispatcher.js +++ b/dispatchers/HomieDispatcher.js @@ -7,9 +7,8 @@ const HomieDevice = require('../homie/homieDevice'); const HomieMQTTClient = require('../homie/HomieMQTTClient'); const Color = require('../Color'); -const DEFAULT_TOPIC_ROOT = 'homie'; +const DEFAULT_TOPIC_ROOT = 'homie/homey'; const DEFAULT_DEVICE_NAME = 'Homey'; -const DEFAULT_DEVICE_ID = 'homey'; const DEFAULT_ZONE = "home"; const DEFAULT_CLASS = "other"; const DEFAULT_PROPERTY_SCALING = "default"; @@ -25,14 +24,7 @@ const PROPERTY_COMMANDS = ['$name', '$retained', '$settable', '$unit', '$datatyp * */ class HomieDispatcher { - get _topicRoot() { - return this.settings && this.settings.homieTopic ? this.settings.homieTopic : ''; - } - get _deviceId() { - return this.settings && this.settings.deviceId ? this.settings.deviceId : DEFAULT_DEVICE_ID; - } - - constructor({ api, mqttClient, deviceManager, system, settings, messageQueue, topicsRegistry }) { + constructor({ api, mqttClient, deviceManager, settings, system, messageQueue, topicsRegistry }) { this.api = api; this._mqttClient = mqttClient; this.homieMQTTClient = new HomieMQTTClient(mqttClient, messageQueue); @@ -40,25 +32,90 @@ class HomieDispatcher { this.system = system; this.messageQueue = messageQueue; this.topicsRegistry = topicsRegistry; - - this.updateSettings(settings); this._nodes = new Map(); this._capabilityInstances = new Map(); } - register() { - if (this._registered) return; - this._registered = true; + applySettings(settings) { + this.settings = settings; - // Wait for the client to be connected, otherwise messages wont be send - if (this._mqttClient.isRegistered()) { - this._initHomieDevice(); - } else { - this._mqttClient.onRegistered.subscribe(() => this._initHomieDevice(), true); + // read config + this.broadcast = settings.broadcastDevices !== false; + this.topic = (settings.homieTopic || DEFAULT_TOPIC_ROOT).replace('{deviceId}', settings.deviceId); + this.topicIncludeClass = settings.topicIncludeClass === true; + this.topicIncludeZone = settings.topicIncludeZone === true; + this.percentageScale = settings.percentageScale || DEFAULT_PROPERTY_SCALING; + this.colorFormat = settings.colorFormat || DEFAULT_COLOR_FORMAT; + this.normalize = !!settings.normalize; + + if (this.normalize) { + this.topic = normalize(this.topic); + } + + // parse topic + const parts = this.topic.split('/'); + this._rootTopicParts = parts.length; + if (this.topicIncludeClass) this._rootTopicParts++; + if (this.topicIncludeZone) this._rootTopicParts++; + this.deviceId = parts.pop(); + this.topicRoot = parts.join('/'); + } + + breakingChanges(settings) { + const hash = JSON.stringify({ + homieTopic: settings.homieTopic, + normalize: settings.normalize, + topicIncludeClass: settings.topicIncludeClass, + topicIncludeZone: settings.topicIncludeZone, + percentageScale: settings.percentageScale, + colorFormat: settings.colorFormat, + broadcastDevices: settings.broadcastDevices + }); + const changed = this._settingsHash !== hash; + this._settingsHash = hash; + return changed; + } + + init(settings, deviceChanges) { + + this.applySettings(settings); + + + // Breaking changes? => Start a new HomieDevice (& destroy current) + if (this.breakingChanges(settings)) { + + Log.info("Recreate HomieDevice with new settings"); + + // Wait for the client to be connected, otherwise messages wont be send + if (this._mqttClient.isRegistered()) { + this._initHomieDevice(); // reboot HomieDevice with new settings + } else { + this._mqttClient.onRegistered.subscribe(() => this._initHomieDevice(), true); + } + } else if (deviceChanges) { // update changed devices only + Log.info("Update settings for changed devices only"); + for (let deviceId of deviceChanges.enabled) { + if (typeof deviceId === 'string') { + this.enableDevice(deviceId); + } + } + for (let deviceId of deviceChanges.disabled) { + if (typeof deviceId === 'string') { + this.disableDevice(deviceId); + } + } } } - + + formatTopic(topic) { + if (topic && topic.substr(0, this.topic.length) === this.topic) { + let parts = topic.split('/').slice(this._rootTopicParts); + topic = [this.topicRoot, this.deviceId, ...parts].join('/'); + } + return topic; + } + _initHomieDevice() { if (this.homieDevice) { this._destroyHomieDevice(); @@ -66,7 +123,7 @@ class HomieDispatcher { Log.info("Create HomieDevice"); this.homieDevice = new HomieDevice(this.deviceConfig); - this.homieDevice.setFirmware(this.system.homeyModelName || 'Homey', this.system.homeyVersion || '2+'); + this.homieDevice.setFirmware(this.settings.deviceId || this.system.homeyModelName || 'Homey', this.system.homeyVersion || '2+'); this._messageCallback = function (topic, value) { Log.info('message: ' + topic + ' with value: ' + value); @@ -77,6 +134,12 @@ class HomieDispatcher { this.homieDevice.on('message', this._messageCallback); this.homieDevice.on('broadcast', this._broadcastCallback); + // format custom topics + const onMessage = this.homieDevice.onMessage; + this.homieDevice.onMessage = (topic, message) => { + onMessage.call(this.homieDevice, this.formatTopic(topic), message); + }; + this.registerDevices(); this.homieDevice.setup(true); @@ -102,56 +165,22 @@ class HomieDispatcher { get deviceConfig() { return { name: this.system.name || DEFAULT_DEVICE_NAME, - device_id: this._deviceId, + device_id: this.deviceId, mqtt: { host: "localhost", port: 1883, - base_topic: this._topicRoot + '/', + base_topic: this.topicRoot ? this.topicRoot + '/' : '', auth: false, username: null, password: null }, mqttClient: this.homieMQTTClient, - settings: { - }, + settings: {}, ip: null, mac: null }; } - updateSettings(settings, deviceChanges) { - settings = settings || {}; - const current = this.settings ? JSON.stringify(this.settings) : null; - this.settings = this.settings || {}; - - this.broadcast = settings.broadcastDevices !== false; - - this.settings.homieTopic = settings.homieTopic === undefined ? DEFAULT_TOPIC_ROOT : settings.homieTopic; - this.settings.deviceId = normalize(settings.deviceId || this.system.name || DEFAULT_DEVICE_ID); - this.settings.topicIncludeClass = settings.topicIncludeClass === true; - this.settings.topicIncludeZone = settings.topicIncludeZone === true; - this.settings.percentageScale = settings.percentageScale || DEFAULT_PROPERTY_SCALING; - this.settings.colorFormat = settings.colorFormat || DEFAULT_COLOR_FORMAT; - - // Breaking changes? => Start a new HomieDevice (& destroy current) - if (current && current !== JSON.stringify(this.settings)) { - Log.info("Recreate HomieDevice with new settings"); - this._initHomieDevice(); // reboot HomieDevice with new settings - } else if (deviceChanges) { // update changed devices only - Log.info("Update settings for changed devices only"); - for (let deviceId of deviceChanges.enabled) { - if (typeof deviceId === 'string') { - this.enableDevice(deviceId); - } - } - for (let deviceId of deviceChanges.disabled) { - if (typeof deviceId === 'string') { - this.disableDevice(deviceId); - } - } - } - } - // Get all devices and add them registerDevices() { Log.info("register devices"); @@ -179,23 +208,26 @@ class HomieDispatcher { } getTopic(device, capability) { - return [ - this._topicRoot, - this._deviceId, + // TODO: some caching? + const topic = [ + ...this.topic.split('/'), ...this.getNodeName(device).split('/'), - capability ? normalize(typeof capability === 'string' ? capability : capability.id) : undefined + capability ? (typeof capability === 'string' ? capability : capability.id) : undefined ].filter(x => x).join('/'); + + return this.normalize ? normalize(topic) : topic; } getNodeName(device) { - let path = [normalize(device.name)]; - if (this.settings.topicIncludeZone) { - path.unshift(device.zone && device.zone.name ? normalize(device.zone.name) : DEFAULT_ZONE); + let path = [device.name]; + if (this.topicIncludeZone) { + path.unshift(device.zone && device.zone.name ? device.zone.name : DEFAULT_ZONE); } - if (this.settings.topicIncludeClass) { - path.unshift(normalize(device.class) || DEFAULT_CLASS); + if (this.topicIncludeClass) { + path.unshift(device.class || DEFAULT_CLASS); } - return path.join('/'); + path = path.filter(x => x).join('/'); + return this.normalize ? normalize(path) : path; } _send(deviceId, propertyOrTopic, value, retained) { @@ -213,7 +245,7 @@ class HomieDispatcher { _sendColor(device, property, { hsv, rgb }, retained) { // Send color for property - switch (this.settings.colorFormat) { + switch (this.colorFormat) { case 'hsv': this._send(device.id, property, `${hsv.h},${hsv.s},${hsv.v}`, retained); break; @@ -255,8 +287,10 @@ class HomieDispatcher { Log.info("Register device: " + device.name); - const name = this.getNodeName(device); + const topic = this.getNodeName(device); + const name = topic.split('/').pop(); let node = this.homieDevice.node(name, device.name, this._convertClass(device.class)); + node.mqttTopic = this.homieDevice.mqttTopic + '/' + topic; // Note: override this._nodes.set(device.id, node); // register // register property topics @@ -268,7 +302,7 @@ class HomieDispatcher { if (capabilities.hasOwnProperty(key)) { const capability = capabilities[key]; const id = capability.id; - const color = (this.settings.colorFormat !== 'values' && id === 'light_hue') ? 'color' : null; + const color = (this.colorFormat !== 'values' && id === 'light_hue') ? 'color' : null; const value = capability.value; const capabilityTitle = color ? 'Color' : (capability.title && typeof capability.title === 'object') ? capability.title['en'] : capability.title; const capabilityName = capabilityTitle || capability.desc || id; @@ -276,7 +310,7 @@ class HomieDispatcher { const dataType = this._convertDataType(capability); if (dataType) { // NOTE: undefined for filtered color formats - const property = node.advertise(color || normalize(id)) + const property = node.advertise(color || (this.normalize ? normalize(id) : id)) .setName(name) .setUnit(this._convertUnit(capability)) .setDatatype(dataType) @@ -418,7 +452,7 @@ class HomieDispatcher { case 'light_hue': case 'light_saturation': case 'light_temperature': - return this.settings.colorFormat === 'values' ? capability.id : this.settings.colorFormat; + return this.colorFormat === 'values' ? capability.id : this.colorFormat; default: const units = capability.units; return units && typeof units === 'object' ? units['en'] : units; @@ -429,7 +463,7 @@ class HomieDispatcher { // percentage if (capability.units === '%') { - switch (this.settings.percentageScale) { + switch (this.percentageScale) { case 'int': if (capability.min === 0 && capability.max === 1) return 'int'; @@ -446,7 +480,7 @@ class HomieDispatcher { } // color - if (this.settings.colorFormat !== 'values') { + if (this.colorFormat !== 'values') { switch (capability.id) { case 'light_hue': return 'color'; // Catch 'color' type @@ -475,12 +509,12 @@ class HomieDispatcher { */ // Catch 'color' format - if (this.settings.colorFormat !== 'values') { + if (this.colorFormat !== 'values') { switch (capability.id) { case 'light_hue': case 'light_saturation': case 'light_temperature': - return this.settings.colorFormat; + return this.colorFormat; } } @@ -488,7 +522,7 @@ class HomieDispatcher { // catch percentage if (capability.units === '%') { - switch (this.settings.percentageScale) { + switch (this.percentageScale) { case 'int': if (capability.min === 0 && capability.max === 1) return '0:100'; @@ -539,7 +573,7 @@ class HomieDispatcher { } // Catch colors - //if (this.settings.colorFormat !== 'values') { + //if (this.colorFormat !== 'values') { if (capability.id === 'light_hue') { capability.id = 'color'; let device = await this.api.devices.getDevice({ id: deviceId }); @@ -556,7 +590,8 @@ class HomieDispatcher { //} try { - const property = node.setProperty(normalize(capability.id)); + const propertyId = this.normalize ? normalize(capability.id) : capability.id; + const property = node.setProperty(propertyId); if (property) { if (this.broadcast) { this._send(deviceId, property, this._formatValue(value, capability)); @@ -604,7 +639,7 @@ class HomieDispatcher { if (split.length === 3) { try { - let color = this.settings.colorFormat === 'rgb' ? Color.RGBtoHSV(...split) : { h: split[0], s: split[1], v: split[2] }; + let color = this.colorFormat === 'rgb' ? Color.RGBtoHSV(...split) : { h: split[0], s: split[1], v: split[2] }; Log.debug("color: " + JSON.stringify(color)); // Note: Homey values are rang 0...1 @@ -675,7 +710,7 @@ class HomieDispatcher { } if (capability.units === '%') { - switch (this.settings.percentageScale) { + switch (this.percentageScale) { case 'int': if (capability.min === 0 && capability.max === 1) return value * 100; @@ -698,7 +733,7 @@ class HomieDispatcher { // Handle percentage scaling if (capability && capability.units === '%') { - switch (this.settings.percentageScale) { + switch (this.percentageScale) { case 'int': if (capability.min === 0 && capability.max === 1) return this._parseValue(value, 'int') / 100.0; diff --git a/dispatchers/SystemStateDispatcher.js b/dispatchers/SystemStateDispatcher.js index 947f648..f079838 100644 --- a/dispatchers/SystemStateDispatcher.js +++ b/dispatchers/SystemStateDispatcher.js @@ -60,25 +60,35 @@ class SystemStateDispatcher { this.mqttClient = mqttClient; this.messageQueue = messageQueue; - this.topic = TOPIC; - - this._init() - .then(() => Log.info("SystemStateDispatcher initialized")) - .catch(error => Log.error(error)); - } - - async _init() { this._registerCallback = this.register.bind(this); this._unregisterCallback = this.unregister.bind(this); this.mqttClient.onRegistered.subscribe(this._registerCallback); this.mqttClient.onUnRegistered.subscribe(this._unregisterCallback); - if (this.mqttClient.isRegistered()) - await this.register(); + } + + async init(settings) { + try { + this.enabled = settings.broadcastSystemState; + this.topic = (settings.systemStateTopic || TOPIC).replace('{deviceId}', settings.deviceId); + + if (this.mqttClient.isRegistered()) + await this.register(); + + Log.info("SystemStateDispatcher initialized"); + } catch (e) { + Log.error('Failed to initialize SystemStateDispatcher'); + Log.debug(e); + } } // Get all devices and add them async register() { + if (!this.enabled) { + await this.unregister(); + return; + } + try { this.registered = true; await this.update(); @@ -93,6 +103,7 @@ class SystemStateDispatcher { async unregister() { this.registered = false; this._resetTimeout(); + Log.debug('System info dispatcher unregistered'); } _resetTimeout() { @@ -104,7 +115,7 @@ class SystemStateDispatcher { async update() { this._resetTimeout(); - if (!this.registered) return; + if (!this.enabled || !this.registered) return; try { // TODO: Create state value messages for each value of interest diff --git a/settings/settings.js b/settings/settings.js index 0cab400..8919fa5 100644 --- a/settings/settings.js +++ b/settings/settings.js @@ -117,7 +117,7 @@ function onHomeyReady(homeyReady){ $("#running").prop("disabled", false); running = !err && result; }); - + showTab(1); getLanguage(); @@ -181,8 +181,9 @@ function onHomeyReady(homeyReady){ reset: function () { // confirm? //if(Homey.confirm("Reset default settings?")){ + const deviceId = hubSettings.systemName; hubSettings = { ...defaultSettings }; - hubSettings.deviceId = hubSettings.systemName; + hubSettings.deviceId = deviceId || 'Homey'; updateValues(); _writeSettings(); //}