zwave-js-ui
Version:
Z-Wave Control Panel and MQTT Gateway
1,114 lines • 91.4 kB
JavaScript
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