UNPKG

zwave-js-ui

Version:

Z-Wave Control Panel and MQTT Gateway

1,114 lines 91.4 kB
import * as fs from 'node:fs'; import * as path from 'node:path'; import * as utils from "./utils.js"; import { AlarmSensorType } from 'zwave-js'; import { CommandClasses } from '@zwave-js/core'; import * as Constants from "./Constants.js"; import { module } from "./logger.js"; import hassCfg from "../hass/configurations.js"; import hassDevices from "../hass/devices.js"; import { storeDir } from "../config/app.js"; import MqttClient from "./MqttClient.js"; import Cron from 'croner'; import crypto from 'node:crypto'; const logger = module('Gateway'); const NODE_PREFIX = 'nodeID_'; const UID_DISCOVERY_PREFIX = process.env.UID_DISCOVERY_PREFIX || 'zwavejs2mqtt_'; const GATEWAY_TYPE = { VALUEID: 0, NAMED: 1, MANUAL: 2, }; const PAYLOAD_TYPE = { TIME_VALUE: 0, VALUEID: 1, RAW: 2, }; const CUSTOM_DEVICES = storeDir + '/customDevices'; let allDevices = hassDevices; // will contain customDevices + hassDevices // watcher initiates a watch on a file. if this fails (e.g., because the file // doesn't exist), instead watch the directory. If the directory watch // triggers, cancel it and try to watch the file again. Meanwhile spam `fn()` // on any change, trusting that it's idempotent. const watchers = new Map(); const watch = (filename, fn) => { try { watchers.set(filename, fs.watch(filename, (e) => { fn(); if (e === 'rename') { watchers.get(filename).close(); watch(filename, fn); } })); } catch { watchers.set(filename, fs.watch(path.dirname(filename), (e, f) => { if (!f || f === 'customDevices.js' || f === 'customDevices.json') { watchers.get(filename).close(); watch(filename, fn); fn(); } })); } }; const customDevicesJsPath = CUSTOM_DEVICES + '.js'; const customDevicesJsonPath = CUSTOM_DEVICES + '.json'; let lastCustomDevicesLoad = null; // loadCustomDevices attempts to load a custom devices file, preferring `.js` // but falling back to `.json` only if a `.js` file does not exist. It stores // a sha of the loaded data, and will skip re-loading any time the data has // not changed. const loadCustomDevices = () => { let loaded = ''; let devices = null; try { if (fs.existsSync(customDevicesJsPath)) { loaded = customDevicesJsPath; devices = require(CUSTOM_DEVICES); } else if (fs.existsSync(customDevicesJsonPath)) { loaded = customDevicesJsonPath; devices = JSON.parse(fs.readFileSync(loaded).toString()); } else { return; } } catch (error) { logger.error(`Failed to load ${loaded}:`, error); return; } const sha = crypto .createHash('sha256') .update(JSON.stringify(devices)) .digest('hex'); if (lastCustomDevicesLoad === sha) { return; } logger.info(`Loading custom devices from ${loaded}`); lastCustomDevicesLoad = sha; allDevices = Object.assign({}, hassDevices, devices); logger.info(`Loaded ${Object.keys(devices).length} custom Hass devices configurations`); }; loadCustomDevices(); watch(customDevicesJsPath, loadCustomDevices); watch(customDevicesJsonPath, loadCustomDevices); export function closeWatchers() { for (const [, watcher] of watchers) { watcher.close(); } } export var GatewayType; (function (GatewayType) { GatewayType[GatewayType["VALUEID"] = 0] = "VALUEID"; GatewayType[GatewayType["NAMED"] = 1] = "NAMED"; GatewayType[GatewayType["MANUAL"] = 2] = "MANUAL"; })(GatewayType || (GatewayType = {})); export var PayloadType; (function (PayloadType) { PayloadType[PayloadType["JSON_TIME_VALUE"] = 0] = "JSON_TIME_VALUE"; PayloadType[PayloadType["VALUEID"] = 1] = "VALUEID"; PayloadType[PayloadType["RAW"] = 2] = "RAW"; })(PayloadType || (PayloadType = {})); export default class Gateway { config; _mqtt; _zwave; topicValues; discovered; topicLevels; _closed; jobs = new Map(); get mqtt() { return this._mqtt; } get zwave() { return this._zwave; } get closed() { return this._closed; } get mqttEnabled() { return this.mqtt && !this.mqtt.disabled; } constructor(config, zwave, mqtt) { this.config = config || { type: 1 }; // clients this._mqtt = mqtt; this._zwave = zwave; } async start() { // gateway configuration this.config.values = this.config.values || []; // Object where keys are topic and values can be both zwave valueId object // or a valueConf if the topic is a broadcast topic this.topicValues = {}; this.discovered = {}; this._closed = false; // topic levels for subscribes using wildecards this.topicLevels = []; if (this.mqttEnabled) { this._mqtt.on('writeRequest', this._onWriteRequest.bind(this)); this._mqtt.on('broadcastRequest', this._onBroadRequest.bind(this)); this._mqtt.on('multicastRequest', this._onMulticastRequest.bind(this)); this._mqtt.on('apiCall', this._onApiRequest.bind(this)); this._mqtt.on('hassStatus', this._onHassStatus.bind(this)); this._mqtt.on('brokerStatus', this._onBrokerStatus.bind(this)); } if (this._zwave) { // needed in order to apply gateway values configs like polling this._zwave.on('nodeInited', this._onNodeInited.bind(this)); // needed to init scheduled jobs this._zwave.on('driverStatus', this._onDriverStatus.bind(this)); if (this.mqttEnabled) { this._zwave.on('nodeStatus', this._onNodeStatus.bind(this)); this._zwave.on('nodeLastActive', this._onNodeLastActive.bind(this)); this._zwave.on('valueChanged', this._onValueChanged.bind(this)); this._zwave.on('nodeRemoved', this._onNodeRemoved.bind(this)); this._zwave.on('notification', this._onNotification.bind(this)); if (this.config.sendEvents) { this._zwave.on('event', this._onEvent.bind(this)); } } // this is async but doesn't need to be awaited await this._zwave.connect(); } else { logger.error('Z-Wave settings are not valid'); } } /** * Schedule a job */ scheduleJob(jobConfig) { if (jobConfig.enabled) { if (jobConfig.runOnInit) { this.runJob(jobConfig).catch((error) => { logger.error(`Error while executing scheduled job "${jobConfig.name}": ${error.message}`); }); } if (jobConfig.cron) { try { const job = new Cron(jobConfig.cron, this.runJob.bind(this, jobConfig)); if (job?.nextRun()) { this.jobs.set(jobConfig.name, job); logger.info(`Scheduled job "${jobConfig.name}" will run at ${job .nextRun() .toISOString()}`); } } catch (error) { logger.error(`Error while scheduling job "${jobConfig.name}": ${error.message}`); } } } } /** * Executes a scheduled job */ async runJob(jobConfig) { logger.info(`Executing scheduled job "${jobConfig.name}"...`); try { await this.zwave.driverFunction(jobConfig.code); } catch (error) { logger.error(`Error executing scheduled job "${jobConfig.name}": ${error.message}`); } const job = this.jobs.get(jobConfig.name); if (job?.nextRun()) { logger.info(`Next scheduled job "${jobConfig.name}" will run at ${job .nextRun() .toISOString()}`); } } /** * Parse the value of the payload received from mqtt * based on the type of the payload and the gateway config */ parsePayload(payload, valueId, valueConf) { try { payload = typeof payload === 'object' && utils.hasProperty(payload, 'value') ? payload.value : payload; // try to parse string to bools if (typeof payload === 'string' && isNaN(parseInt(payload))) { if (/\btrue\b|\bon\b|\block\b/gi.test(payload)) payload = true; else if (/\bfalse\b|\boff\b|\bunlock\b/gi.test(payload)) { payload = false; } } // on/off becomes 100%/0% if (typeof payload === 'boolean' && valueId.type === 'number') { payload = payload ? 0xff : valueId.min; } // 1/0 becomes true/false if (typeof payload === 'number' && valueId.type === 'boolean') { payload = payload > 0; } if (valueId.commandClass === CommandClasses['Binary Toggle Switch']) { payload = 1; } else if (valueId.commandClass === CommandClasses['Multilevel Toggle Switch']) { payload = valueId.value > 0 ? 0 : 0xff; } const hassDevice = this.discovered[valueId.id]; // Hass payload parsing if (hassDevice) { // map modes coming from hass if (valueId.list && isNaN(parseInt(payload))) { // for thermostat_fan_mode command class use the fan_mode_map if (valueId.commandClass === CommandClasses['Thermostat Fan Mode'] && hassDevice.fan_mode_map) { payload = hassDevice.fan_mode_map[payload]; } else if (valueId.commandClass === CommandClasses['Thermostat Mode'] && hassDevice.mode_map) { // for other command classes use the mode_map payload = hassDevice.mode_map[payload]; } } else if (hassDevice.type === 'cover' && valueId.property === 'targetValue') { // ref issue https://github.com/zwave-js/zwave-js-ui/issues/3862 if (payload === (hassDevice.discovery_payload.payload_stop ?? 'STOP')) { this._zwave .writeValue({ ...valueId, property: 'Up', }, false) .catch(() => { }); return null; } } } if (valueConf) { if (this._isValidOperation(valueConf.postOperation)) { let op = valueConf.postOperation; // revert operation to write if (op.includes('/')) op = op.replace(/\//, '*'); else if (op.includes('*')) op = op.replace(/\*/g, '/'); else if (op.includes('+')) op = op.replace(/\+/, '-'); else if (op.includes('-')) op = op.replace(/-/, '+'); payload = eval(`${payload}${op}`); } if (valueConf.parseReceive) { const node = this._zwave.nodes.get(valueId.nodeId); const parsedVal = this._evalFunction(valueConf.receiveFunction, valueId, payload, node); if (parsedVal != null) { payload = parsedVal; } } } } catch (error) { logger.error(`Error while parsing payload ${payload} for valueID ${valueId.id}`); } return payload; } /** * Method used to cancel all scheduled jobs */ cancelJobs() { // cancel jobs for (const [, job] of this.jobs) { job.stop(); } this.jobs.clear(); } /** * Method used to close clients connection, use this before destroy */ async close() { this._closed = true; logger.info('Closing Gateway...'); if (this._zwave) { await this._zwave.close(); } this.cancelJobs(); // close mqtt client after zwave connection is closed if (this.mqttEnabled) { await this._mqtt.close(); } } /** * Calculates the node topic based on gateway settings */ nodeTopic(node) { const topic = []; if (node.loc && !this.config.ignoreLoc) topic.push(node.loc); switch (this.config.type) { case GATEWAY_TYPE.MANUAL: case GATEWAY_TYPE.NAMED: topic.push(node.name ? node.name : NODE_PREFIX + node.id); break; case GATEWAY_TYPE.VALUEID: if (!this.config.nodeNames) { topic.push(node.id); } else { topic.push(node.name ? node.name : NODE_PREFIX + node.id); } break; default: topic.push(NODE_PREFIX + node.id); } // clean topic parts for (let i = 0; i < topic.length; i++) { topic[i] = utils.sanitizeTopic(topic[i]); } return topic.join('/'); } /** * Calculates the valueId topic based on gateway settings * */ valueTopic(node, valueId, returnObject = false) { const topic = []; let valueConf; // check if this value is in configuration values array const values = this.config.values.filter((v) => v.device === node.deviceId); if (values && values.length > 0) { const vID = this._getIdWithoutNode(valueId); valueConf = values.find((v) => v.value.id === vID); } if (valueConf && valueConf.topic) { topic.push(node.name ? node.name : NODE_PREFIX + valueId.nodeId); topic.push(valueConf.topic); } let targetTopic; if (returnObject && valueId.targetValue) { const targetValue = node.values[valueId.targetValue]; if (targetValue) { targetTopic = this.valueTopic(node, targetValue, false); } } // if is not in configuration values array get the topic // based on gateway type if manual type this will be skipped if (topic.length === 0) { switch (this.config.type) { case GATEWAY_TYPE.NAMED: topic.push(node.name ? node.name : NODE_PREFIX + valueId.nodeId); topic.push(Constants.commandClass(valueId.commandClass)); topic.push('endpoint_' + (valueId.endpoint || 0)); topic.push(utils.removeSlash(valueId.propertyName)); if (valueId.propertyKey !== undefined) { topic.push(utils.removeSlash(valueId.propertyKey)); } break; case GATEWAY_TYPE.VALUEID: if (!this.config.nodeNames) { topic.push(valueId.nodeId); } else { topic.push(node.name ? node.name : NODE_PREFIX + valueId.nodeId); } topic.push(valueId.commandClass); topic.push(valueId.endpoint || '0'); topic.push(utils.removeSlash(valueId.property)); if (valueId.propertyKey !== undefined) { topic.push(utils.removeSlash(valueId.propertyKey)); } break; } } // if there is a valid topic for this value publish it if (topic.length > 0) { // add location prefix if (node.loc && !this.config.ignoreLoc) topic.unshift(node.loc); // clean topic parts for (let i = 0; i < topic.length; i++) { topic[i] = utils.sanitizeTopic(topic[i]); } const toReturn = { topic: topic.join('/'), valueConf: valueConf, targetTopic: targetTopic, }; return returnObject ? toReturn : toReturn.topic; } else { return null; } } /** * Rediscover all hass devices of this node */ rediscoverNode(nodeID) { const node = this._zwave.nodes.get(nodeID); if (node) { // delete all discovered values this._onNodeRemoved(node); node.hassDevices = {}; // rediscover all values const nodeDevices = allDevices[node.deviceId] || []; nodeDevices.forEach((device) => this.discoverDevice(node, device)); // discover node values (that are not part of a device) // iterate prioritized first, then the remaining for (const id of this._getPriorityCCFirst(node.values)) { this.discoverValue(node, id); } this._zwave.emitNodeUpdate(node, { hassDevices: node.hassDevices, }); } } /** * Disable the discovery of all devices of this node * */ disableDiscovery(nodeId) { const node = this._zwave.nodes.get(nodeId); if (node && node.hassDevices) { for (const id in node.hassDevices) { node.hassDevices[id].ignoreDiscovery = true; } this._zwave.emitNodeUpdate(node, { hassDevices: node.hassDevices, }); } } /** * Publish a discovery payload to discover a device in hass using mqtt auto discovery * */ publishDiscovery(hassDevice, nodeId, options = {}) { try { if (!this.mqttEnabled || !this.config.hassDiscovery) { logger.debug('Enable MQTT gateway and hass discovery to use this function'); return; } logger.log('debug', `${options.deleteDevice ? 'Removing' : 'Publishing'} discovery: %o`, hassDevice); this.setDiscovery(nodeId, hassDevice, options.deleteDevice); if (this.config.payloadType === PAYLOAD_TYPE.RAW) { const p = hassDevice.discovery_payload; const template = 'value' + (utils.hasProperty(p, 'payload_on') && utils.hasProperty(p, 'payload_off') ? " == 'true'" : ''); for (const k in p) { if (typeof p[k] === 'string') { p[k] = p[k].replace(/value_json\.value/g, template); } } } const skipDiscovery = hassDevice.ignoreDiscovery || (this.config.manualDiscovery && !options.forceUpdate); if (!skipDiscovery) { this._mqtt.publish(hassDevice.discoveryTopic, options.deleteDevice ? '' : hassDevice.discovery_payload, { qos: 0, retain: this.config.retainedDiscovery || false }, this.config.discoveryPrefix); } if (options.forceUpdate) { this._zwave.updateDevice(hassDevice, nodeId, options.deleteDevice); } } catch (error) { logger.log('error', `Error while publishing discovery for node ${nodeId}: ${error.message}. Hass device: %o`, hassDevice); } } /** * Set internal discovery reference of a valueId * */ setDiscovery(nodeId, hassDevice, deleteDevice = false) { for (let k = 0; k < hassDevice.values.length; k++) { const vId = nodeId + '-' + hassDevice.values[k]; if (deleteDevice && this.discovered[vId]) { delete this.discovered[vId]; } else { this.discovered[vId] = hassDevice; } } } /** * Rediscover all nodes and their values/devices * */ rediscoverAll() { // skip discovery if discovery not enabled if (!this.config.hassDiscovery) return; const nodes = this._zwave.nodes ?? []; for (const [nodeId, node] of nodes) { const devices = node.hassDevices || {}; for (const id in devices) { const d = devices[id]; if (d && d.discoveryTopic && d.discovery_payload) { this.publishDiscovery(d, nodeId); } } // end foreach hassdevice } } /** * Discover an hass device (from customDevices.js|json) */ discoverDevice(node, hassDevice) { if (!this.mqttEnabled || !this.config.hassDiscovery) { logger.info('Enable MQTT gateway and hass discovery to use this function'); return; } const hassID = hassDevice ? hassDevice.type + '_' + hassDevice.object_id : null; try { if (hassID && !node.hassDevices[hassID]) { // discover the device let payload; // copy the configuration without edit the original object hassDevice = utils.copy(hassDevice); if (hassDevice.type === 'climate') { payload = hassDevice.discovery_payload; const mode = node.values[payload.mode_state_topic]; let setId; if (mode !== undefined) { setId = hassDevice.setpoint_topic && hassDevice.setpoint_topic[mode.value] ? hassDevice.setpoint_topic[mode.value] : hassDevice.default_setpoint; // only setup modes if a state topic was defined payload.mode_state_template = this._getMappedValuesInverseTemplate(hassDevice.mode_map, 'off'); payload.mode_state_topic = this._mqtt.getTopic(this.valueTopic(node, mode)); payload.mode_command_topic = payload.mode_state_topic + '/set'; } else { setId = hassDevice.default_setpoint; } // set properties dynamically using their configuration values this._setDiscoveryValue(payload, 'max_temp', node); this._setDiscoveryValue(payload, 'min_temp', node); const setpoint = node.values[setId]; payload.temperature_state_topic = this._mqtt.getTopic(this.valueTopic(node, setpoint)); payload.temperature_command_topic = payload.temperature_state_topic + '/set'; const action = node.values[payload.action_topic]; if (action) { payload.action_topic = this._mqtt.getTopic(this.valueTopic(node, action)); if (hassDevice.action_map) { payload.action_template = this._getMappedValuesTemplate(hassDevice.action_map, 'idle'); } } const fan = node.values[payload.fan_mode_state_topic]; if (fan !== undefined) { payload.fan_mode_state_topic = this._mqtt.getTopic(this.valueTopic(node, fan)); payload.fan_mode_command_topic = payload.fan_mode_state_topic + '/set'; if (hassDevice.fan_mode_map) { payload.fan_mode_state_template = this._getMappedValuesInverseTemplate(hassDevice.fan_mode_map, 'auto'); } } const currTemp = node.values[payload.current_temperature_topic]; if (currTemp !== undefined) { payload.current_temperature_topic = this._mqtt.getTopic(this.valueTopic(node, currTemp)); if (currTemp.unit) { payload.temperature_unit = currTemp.unit.includes('C') ? 'C' : 'F'; } // hass will default the precision to 0.1 for Celsius and 1.0 for Fahrenheit. // 1.0 is not granular enough as a default and there seems to be no harm in making it more precise. if (!payload.precision) payload.precision = 0.1; } } else { payload = hassDevice.discovery_payload; const topics = {}; // populate topics object with valueId: valueTopic for (let i = 0; i < hassDevice.values.length; i++) { const v = hassDevice.values[i]; // the value id topics[v] = node.values[v] ? this._mqtt.getTopic(this.valueTopic(node, node.values[v])) : null; } // set the correct command/state topics for (const key in payload) { if (key.indexOf('topic') >= 0 && topics[payload[key]]) { payload[key] = topics[payload[key]] + (key.indexOf('command') >= 0 || key.indexOf('set_') >= 0 ? '/set' : ''); } } } if (payload) { const nodeName = this._getNodeName(node, this.config.ignoreLoc); // Set device information using node info payload.device = this._deviceInfo(node, nodeName); this.setDiscoveryAvailability(node, payload); hassDevice.object_id = utils .sanitizeTopic(hassDevice.object_id, true) .toLocaleLowerCase(); // Set a friendly name for this component payload.name = this._getEntityName(node, undefined, hassDevice, this.config.entityTemplate, this.config.ignoreLoc); // set a unique id for the component payload.unique_id = UID_DISCOVERY_PREFIX + this._zwave.homeHex + '_Node' + node.id + '_' + hassDevice.object_id; const discoveryTopic = this._getDiscoveryTopic(hassDevice, nodeName); hassDevice.discoveryTopic = discoveryTopic; // This configuration is not stored in nodes.json hassDevice.persistent = false; hassDevice.ignoreDiscovery = !!hassDevice.ignoreDiscovery; node.hassDevices[hassID] = hassDevice; this.publishDiscovery(hassDevice, node.id); } } } catch (error) { logger.error(`Error while discovering device ${hassID} of node ${node.id}: ${error.message}`, error); } } /** * Discover climate devices * */ discoverClimates(node) { // https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/deviceClasses.json#L177 // check if device it's a thermostat if (!node.deviceClass || node.deviceClass.generic !== 0x08) { return; } try { const nodeDevices = allDevices[node.deviceId] || []; // skip if there is already a climate device if (nodeDevices.length > 0 && nodeDevices.find((d) => d.type === 'climate')) { return; } // arrays of strings valueIds (without the node prefix) const setpoints = []; const temperatures = []; const modes = []; const actions = []; for (const vId in node.values) { const v = node.values[vId]; if (v.commandClass === CommandClasses['Thermostat Setpoint'] && v.property === 'setpoint') { setpoints.push(vId); } else if (v.commandClass === CommandClasses['Multilevel Sensor'] && v.property === 'Air temperature') { temperatures.push(vId); } else if (v.commandClass === CommandClasses['Thermostat Mode'] && v.property === 'mode') { modes.push(vId); } else if (v.commandClass === CommandClasses['Thermostat Operating State'] && v.property === 'state') { actions.push(vId); } } // TODO: if the device supports multiple endpoints how could we identify the correct one to use? const temperatureId = temperatures[0]; if (setpoints.length === 0) { logger.warn('Unable to discover climate device, there is no valid setpoint valueId'); return; } // generic configuration const config = utils.copy(hassCfg.thermostat); // set empty config.values config.values = []; if (temperatureId) { config.discovery_payload.current_temperature_topic = temperatureId; config.values.push(temperatureId); } else { delete config.discovery_payload.current_temperature_template; delete config.discovery_payload.current_temperature_topic; } // take the first as valid const modeId = modes[0]; // some thermostats could support just one mode so haven't a thermostat mode CC if (modeId) { config.values.push(modeId); const mode = node.values[modeId]; config.discovery_payload.mode_state_topic = modeId; config.discovery_payload.mode_command_topic = modeId + '/set'; // [0, 1, 2 ... ] (['off', 'heat', 'cold', ...]) const availableModes = mode.states.map((s) => s.value); // Hass accepted modes as per: https://www.home-assistant.io/integrations/climate.mqtt/#modes const allowedModes = [ 'off', 'heat', 'cool', 'auto', 'dry', 'fan_only', ]; // Z-Wave modes: https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/commandclass/ThermostatModeCC.ts#L54 // up to 0x1F modes const hassModes = [ 'off', // Off 'heat', // Heat 'cool', // Cool 'auto', // Auto undefined, // Aux undefined, // Resume (on) 'fan_only', // Fan undefined, // Furnance 'dry', // Dry undefined, // Moist 'auto', // Auto changeover 'heat', // Energy heat 'cool', // Energy cool 'off', // Away undefined, // No Z-Wave mode 0x0e 'heat', // Full power undefined, // Up to 0x1f (manufacturer specific) ]; config.mode_map = {}; config.setpoint_topic = {}; // for all available modes update the modes map and setpoint topics for (const m of availableModes) { if (hassModes[m] === undefined) continue; let hM = hassModes[m]; // it could happen that mode_map already have defined a mode for this value, in this case // map that mode to the first one available in the allowed hass modes let i = 1; // skip 'off' while (config.discovery_payload.modes.includes(hM) && i < allowedModes.length) { hM = allowedModes[i++]; } config.mode_map[hM] = m; config.discovery_payload.modes.push(hM); if (m > 0) { // find the mode setpoint, ignore off const setId = setpoints.find((v) => v.endsWith('-' + m)); const setpoint = node.values[setId]; if (setpoint) { config.values.push(setId); config.setpoint_topic[m] = setId; } else { // Use first one, if no specific SP found config.values.push(setpoints[0]); config.setpoint_topic[m] = setpoints[0]; } } } // set the default setpoint to 'heat' or to the first setpoint available config.default_setpoint = config.setpoint_topic[1] || config.setpoint_topic[Object.keys(config.setpoint_topic)[0]]; } else { config.default_setpoint = setpoints[0]; delete config.discovery_payload.modes; delete config.discovery_payload.mode_state_template; } if (actions.length > 0) { const actionId = actions[0]; config.values.push(actionId); config.discovery_payload.action_topic = actionId; const action = node.values[actionId]; // [0, 1, 2 ... ] list of value fields from objects in states list const availableActions = (action.states.map((state) => state.value)); // Hass accepted actions as per https://www.home-assistant.io/integrations/climate.mqtt/#action_topic: // ['off', 'heating', 'cooling', 'drying', 'idle', 'fan'] // Z-Wave actions/states: https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/commandclass/ThermostatOperatingStateCC.ts#L43 const hassActionMap = [ 'idle', 'heating', 'cooling', 'fan', 'idle', 'idle', 'fan', 'heating', 'heating', 'cooling', 'heating', 'heating', // 3rd Stage Aux Heat ]; config.action_map = {}; // for all available actions update the actions map for (const availableAction of availableActions) { const hassAction = hassActionMap[availableAction]; if (hassAction === undefined) continue; config.action_map[availableAction] = hassAction; } } // add the new climate config to the nodeDevices so it will be // discovered later when we call `discoverDevice` nodeDevices.push(config); logger.log('info', 'New climate device discovered: %o', config); allDevices[node.deviceId] = nodeDevices; } catch (error) { logger.error('Unable to discover climate device.', error); } } /** * Try to guess the best way to discover this valueId in Hass */ discoverValue(node, vId) { if (!this.mqttEnabled || !this.config.hassDiscovery) { logger.debug('Enable MQTT gateway and hass discovery to use this function'); return; } const valueId = node.values[vId]; // if the node is not ready means we don't have all values added yet so we are not sure to discover this value properly if (!valueId || this.discovered[valueId.id] || !node.ready) return; try { const result = this.valueTopic(node, valueId, true); if (!result || !result.topic) return; const valueConf = result.valueConf; const getTopic = this._mqtt.getTopic(result.topic); const setTopic = result.targetTopic ? this._mqtt.getTopic(result.targetTopic, true) : null; const nodeName = this._getNodeName(node, this.config.ignoreLoc); let cfg; const cmdClass = valueId.commandClass; const deviceClass = node.endpoints[valueId.endpoint]?.deviceClass ?? node.deviceClass; switch (cmdClass) { case CommandClasses['Binary Switch']: case CommandClasses['All Switch']: case CommandClasses['Binary Toggle Switch']: if (valueId.isCurrentValue) { cfg = utils.copy(hassCfg.switch); } else return; break; case CommandClasses['Barrier Operator']: if (valueId.isCurrentValue) { cfg = utils.copy(hassCfg.barrier_state); cfg.discovery_payload.position_topic = getTopic; } else return; break; case CommandClasses['Multilevel Switch']: case CommandClasses['Multilevel Toggle Switch']: if (valueId.isCurrentValue) { const specificDeviceClass = Constants.specificDeviceClass(deviceClass.generic, deviceClass.specific); // Use a cover_position configuration if ... if ([ 'specific_type_class_a_motor_control', 'specific_type_class_b_motor_control', 'specific_type_class_c_motor_control', 'specific_type_class_motor_multiposition', 'specific_type_motor_multiposition', ].includes(specificDeviceClass) || node.deviceId === '615-0-258' // Issue #3088 ) { cfg = utils.copy(hassCfg.cover_position); cfg.discovery_payload.command_topic = setTopic; cfg.discovery_payload.position_topic = getTopic; cfg.discovery_payload.set_position_topic = cfg.discovery_payload.command_topic; cfg.discovery_payload.position_template = '{{ value_json.value | round(0) }}'; cfg.discovery_payload.position_open = 99; cfg.discovery_payload.position_closed = 0; cfg.discovery_payload.payload_open = 99; cfg.discovery_payload.payload_close = 0; } else { cfg = utils.copy(hassCfg.light_dimmer); cfg.discovery_payload.supported_color_modes = [ 'brightness', ]; cfg.discovery_payload.brightness_state_topic = getTopic; cfg.discovery_payload.brightness_command_topic = setTopic; } } else return; break; case CommandClasses['Door Lock']: if (valueId.isCurrentValue) { // lock state cfg = utils.copy(hassCfg.lock); } else { return; } break; case CommandClasses['Sound Switch']: // https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/commandclass/SoundSwitchCC.ts if (valueId.property === 'volume') { // volume control cfg = utils.copy(hassCfg.volume_dimmer); cfg.discovery_payload.brightness_state_topic = getTopic; cfg.discovery_payload.command_topic = getTopic + '/set'; cfg.discovery_payload.brightness_command_topic = cfg.discovery_payload.command_topic; } else { return; } break; case CommandClasses['Color Switch']: if (valueId.property === 'currentColor' && valueId.propertyKey === undefined) { cfg = this._addRgbColorSwitch(node, valueId); } else return; break; case CommandClasses['Central Scene']: case CommandClasses['Scene Activation']: cfg = utils.copy(hassCfg.central_scene); // Combile unique Object id, by using all possible scenarios cfg.object_id = utils.joinProps(cfg.object_id, valueId.property, valueId.propertyKey); if (valueId.value?.unit) { cfg.discovery_payload.value_template = "{{ value_json.value.value | default('') }}"; } break; case CommandClasses['Binary Sensor']: { // https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/commandclass/BinarySensorCC.ts#L41 // change the sensorTypeName to use directly valueId.property, as the old way was returning a number // add a comment which shows the old way of achieving this value. This change fixes the Binary Sensor // discovery let sensorTypeName = valueId.property.toString(); if (sensorTypeName) { sensorTypeName = utils.sanitizeTopic(sensorTypeName.toLocaleLowerCase(), true); } // TODO: Implement all BinarySensorTypes // Use default Binary sensor, and replace based on sensorTypeName // till now only one type is using the reverse on/off values as states switch (sensorTypeName) { // normal case 'presence': case 'smoke': case 'gas': cfg = this._getBinarySensorConfig(sensorTypeName); break; // reverse case 'lock': cfg = this._getBinarySensorConfig(sensorTypeName, true); break; // moisture - normal case 'contact': case 'water': cfg = this._getBinarySensorConfig(Constants.deviceClass.sensor_binary.MOISTURE); break; // safety - normal case 'co': case 'co2': case 'tamper': cfg = this._getBinarySensorConfig(Constants.deviceClass.sensor_binary.SAFETY); break; // problem - normal case 'alarm': cfg = this._getBinarySensorConfig(Constants.deviceClass.sensor_binary.PROBLEM); break; // connectivity - normal case 'router': cfg = this._getBinarySensorConfig(Constants.deviceClass.sensor_binary .CONNECTIVITY); break; // battery - normal case 'battery_low': cfg = this._getBinarySensorConfig(Constants.deviceClass.sensor_binary.BATTERY); break; default: // in the end build the basic cfg if all fails cfg = utils.copy(hassCfg.binary_sensor); } cfg.object_id = sensorTypeName; if (valueConf) { if (valueConf.device_class) { cfg.discovery_payload.device_class = valueConf.device_class; cfg.object_id = valueConf.device_class; } // binary sensors doesn't support icons } break; } case CommandClasses['Alarm Sensor']: // https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/commandclass/AlarmSensorCC.ts#L40 if (valueId.property === 'state') { cfg = this._getBinarySensorConfig(Constants.deviceClass.sensor_binary.PROBLEM); cfg.object_id += AlarmSensorType[valueId.propertyKey] ? '_' + AlarmSensorType[valueId.propertyKey] : ''; } else { return; } break; case CommandClasses.Basic: case CommandClasses.Notification: // only support basic events if (cmdClass === CommandClasses.Basic && valueId.property !== 'event') { return; } // Try to define Binary sensor if (valueId.states?.length === 2) { let off = 0; // set default off to 0. let discoveredObjectId = valueId.propertyKey; switch (valueId.propertyKeyName) { case 'Access Control': cfg = this._getBinarySensorConfig(Constants.deviceClass.sensor_binary.LOCK); off = 23; // Closed state break; case 'Cover status': cfg = this._getBinarySensorConfig(Constants.deviceClass.sensor_binary.OPENING); break; case 'Door state (simple)': cfg = this._getBinarySensorConfig(Constants.deviceClass.sensor_binary.DOOR); off = 1; // Door closed on payload 1 break; case 'Alarm status': case 'Dust in device status': case 'Load error status': case 'Over-current status': case 'Over-load status': case 'Hardware status': cfg = this._getBinarySensorConfig(Constants.deviceClass.sensor_binary.PROBLEM); break; case 'Heat sensor status': cfg = this._getBinarySensorConfig(Constants.deviceClass.sensor_binary.HEAT); break; case 'Motion sensor status': cfg = this._getBinarySensorConfig(Constants.deviceClass.sensor_binary.MOTION); break; case 'Water Alarm': cfg = this._getBinarySensorConfig(Constants.deviceClass.sensor_binary .MOISTURE); break; // sensor status has multiple Properties. therefore is g