Skip to content

Commit

Permalink
ZwaveJS UI - Multi-level switch support (Dimmer and Curtains) (#2061)
Browse files Browse the repository at this point in the history
Co-authored-by: Stéphane Escandell <[email protected]>
  • Loading branch information
sescandell and Stéphane Escandell authored May 13, 2024
1 parent 88e10e2 commit 69f6d03
Show file tree
Hide file tree
Showing 16 changed files with 10,604 additions and 1,680 deletions.
388 changes: 354 additions & 34 deletions server/services/zwavejs-ui/lib/constants.js

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions server/services/zwavejs-ui/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const { publish } = require('./zwaveJSUI.publish');
const { scan } = require('./zwaveJSUI.scan');
const { saveConfiguration } = require('./zwaveJSUI.saveConfiguration');
const { setValue } = require('./zwaveJSUI.setValue');
const { getZwaveJsDevice } = require('./zwaveJSUI.getZwaveJsDevice');
const { getDevice } = require('./zwaveJSUI.getDevice');

/**
* @description Z-Wave JS UI handler.
Expand All @@ -26,12 +28,15 @@ const ZwaveJSUIHandler = function ZwaveJSUIHandler(gladys, mqttLibrary, serviceI
this.configured = false;
this.connected = false;
this.devices = [];
this.zwaveJSDevices = [];
};

ZwaveJSUIHandler.prototype.init = init;
ZwaveJSUIHandler.prototype.connect = connect;
ZwaveJSUIHandler.prototype.disconnect = disconnect;
ZwaveJSUIHandler.prototype.getConfiguration = getConfiguration;
ZwaveJSUIHandler.prototype.getDevice = getDevice;
ZwaveJSUIHandler.prototype.getZwaveJsDevice = getZwaveJsDevice;
ZwaveJSUIHandler.prototype.handleNewMessage = handleNewMessage;
ZwaveJSUIHandler.prototype.onNewDeviceDiscover = onNewDeviceDiscover;
ZwaveJSUIHandler.prototype.onNodeValueUpdated = onNodeValueUpdated;
Expand Down
13 changes: 13 additions & 0 deletions server/services/zwavejs-ui/lib/zwaveJSUI.getDevice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* @description This will return the Gladys device.
* @param {string} nodeId - The gladys device id.
* @returns {object} The Gladys Device.
* @example zwaveJSUI.getDevice('zwavejs-ui:5');
*/
function getDevice(nodeId) {
return this.devices.find((n) => n.external_id === nodeId);
}

module.exports = {
getDevice,
};
15 changes: 15 additions & 0 deletions server/services/zwavejs-ui/lib/zwaveJSUI.getZwaveJsDevice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* @description This will return the zwaveJs device.
* @param {string} gladysDeviceId - The gladys device id.
* @returns {object} The zwaveJsDevice.
* @example zwaveJSUI.getZwaveJsDevice("zwavejs-ui:5");
*/
function getZwaveJsDevice(gladysDeviceId) {
const deviceId = parseInt(gladysDeviceId.split(':')[1], 10);

return this.zwaveJSDevices.find((n) => n.id === deviceId);
}

module.exports = {
getZwaveJsDevice,
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ const { convertToGladysDevice } = require('../utils/convertToGladysDevice');
*/
async function onNewDeviceDiscover(data) {
const devices = [];
const zwaveDevices = [];
data.result.forEach((zwaveJSDevice) => {
if (zwaveJSDevice.name && zwaveJSDevice.name.length > 0) {
zwaveDevices.push(zwaveJSDevice);
devices.push(convertToGladysDevice(this.serviceId, zwaveJSDevice));
}
});
this.devices = devices;
this.zwaveJSDevices = zwaveDevices;

await this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, {
type: WEBSOCKET_MESSAGE_TYPES.ZWAVEJS_UI.SCAN_COMPLETED,
});
Expand Down
61 changes: 38 additions & 23 deletions server/services/zwavejs-ui/lib/zwaveJSUI.onNodeValueUpdated.js
Original file line number Diff line number Diff line change
@@ -1,47 +1,62 @@
const get = require('get-value');
const Promise = require('bluebird');
const { EVENTS } = require('../../../utils/constants');
const { STATES } = require('./constants');
const { cleanNames, getDeviceFeatureId } = require('../utils/convertToGladysDevice');
const { getDeviceFeatureId } = require('../utils/convertToGladysDevice');
const getProperty = require('../utils/getProperty');

/**
* @description This will be called when new Z-Wave node value is updated.
* @param {object} message - Data sent by ZWave JS UI.
* @returns {Promise} - Promise execution.
* @example zwaveJSUI.onNodeValueUpdated({data: [{node}, {value}]});
*/
async function onNodeValueUpdated(message) {
function onNodeValueUpdated(message) {
// A value has been updated: https://zwave-js.github.io/node-zwave-js/#/api/node?id=quotvalue-addedquot-quotvalue-updatedquot-quotvalue-removedquot
const messageNode = message.data[0];
const updatedValue = message.data[1];
const { commandClassName, propertyName, propertyKeyName, endpoint, newValue } = updatedValue;
const comClassNameClean = cleanNames(commandClassName);
const propertyNameClean = cleanNames(propertyName);
const propertyKeyNameClean = cleanNames(propertyKeyName);
let statePath = `${comClassNameClean}.${propertyNameClean}`;
if (propertyKeyNameClean !== '') {
statePath += `.${propertyKeyNameClean}`;
}

const nodeId = `zwavejs-ui:${messageNode.id}`;
const node = this.devices.find((n) => n.external_id === nodeId);
const node = this.getDevice(nodeId);
if (!node) {
return;
return Promise.resolve();
}

const featureId = getDeviceFeatureId(messageNode.id, commandClassName, endpoint, propertyName, propertyKeyName);
const nodeFeature = node.features.find((f) => f.external_id === featureId);
if (!nodeFeature) {
return;
const zwaveJSNode = this.getZwaveJsDevice(nodeId);
if (!zwaveJSNode) {
return Promise.resolve();
}

const valueConverter = get(STATES, statePath);
const convertedValue = valueConverter !== undefined ? valueConverter(newValue) : null;
const valueConverters = getProperty(STATES, commandClassName, propertyName, propertyKeyName, zwaveJSNode.deviceClass);

if (convertedValue !== null) {
await this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, {
device_feature_external_id: nodeFeature.external_id,
state: convertedValue,
});
if (!valueConverters) {
return Promise.resolve();
}

return Promise.map(
valueConverters,
async (valueConverter) => {
const externalId = getDeviceFeatureId(
messageNode.id,
commandClassName,
endpoint,
valueConverter.property_name || propertyName,
valueConverter.property_name ? valueConverter.property_key_name || '' : propertyKeyName,
valueConverter.feature_name || '',
);

if (node.features.some((f) => f.external_id === externalId)) {
const convertedValue = valueConverter.converter(newValue);
if (convertedValue !== null) {
await this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, {
device_feature_external_id: externalId,
state: convertedValue,
});
}
}
},
{ concurrency: 2 },
);
}

module.exports = {
Expand Down
148 changes: 91 additions & 57 deletions server/services/zwavejs-ui/lib/zwaveJSUI.setValue.js
Original file line number Diff line number Diff line change
@@ -1,35 +1,29 @@
const get = require('get-value');
const Promise = require('bluebird');
const { BadParameters } = require('../../../utils/coreErrors');
const { COMMANDS } = require('./constants');
const { cleanNames } = require('../utils/convertToGladysDevice');
const { ACTIONS } = require('./constants');
const { getDeviceFeatureId } = require('../utils/convertToGladysDevice');
const getProperty = require('../utils/getProperty');

/**
* @description Returns the command wrapper.
* @description Returns the action wrapper.
* @param {object} zwaveJsNode - The zWaveJsDevice node.
* @param {object} nodeFeature - The feature.
* @returns {object} The Command Class command.
* @returns {object} The Command Class action.
* @example
* getCommand({command_class_name: 'Notification', property: 'Home Security', property_key: 'Cover Status'})
* getAction(
* {id: 5, deviceClass: { basic: 4, generic: 17, specific: 6}},
* {command_class_name: 'Notification', property: 'Home Security', property_key: 'Cover Status'}
* )
*/
function getCommand(nodeFeature) {
let commandPath = `${cleanNames(nodeFeature.command_class_name)}.${cleanNames(nodeFeature.property_name)}`;
const propertyKeyNameClean = cleanNames(nodeFeature.property_key_name);
if (propertyKeyNameClean !== '') {
commandPath += `.${propertyKeyNameClean}`;
}

return get(COMMANDS, commandPath);
}

/**
* @description Returns a node from its external id.
* @param {Array} nodes - All nodes available.
* @param {string} nodeId - The node to find.
* @returns {object} The node if found.
* @example
* getNode([{external_id: 'zwavejs-ui:3'}], 'zwavejs-ui:3')
*/
function getNode(nodes, nodeId) {
return nodes.find((n) => n.external_id === nodeId);
function getAction(zwaveJsNode, nodeFeature) {
return getProperty(
ACTIONS,
nodeFeature.command_class_name,
nodeFeature.property_name,
nodeFeature.property_key_name,
zwaveJsNode.deviceClass,
nodeFeature.feature_name,
);
}

/**
Expand All @@ -49,57 +43,97 @@ function getNodeFeature(node, nodeFeatureId) {

/**
* @description Set the new device value from Gladys to MQTT.
* @param {object} device - Updated Gladys device.
* @param {object} deviceFeature - Updated Gladys device feature.
* @param {object} gladysDevice - Updated Gladys device.
* @param {object} gladysFeature - Updated Gladys device feature.
* @param {string|number} value - The new device feature value.
* @returns {Promise} - The execution promise.
* @example
* setValue(device, deviceFeature, 0);
*/
function setValue(device, deviceFeature, value) {
if (!deviceFeature.external_id.startsWith('zwavejs-ui:')) {
async function setValue(gladysDevice, gladysFeature, value) {
if (!gladysFeature.external_id.startsWith('zwavejs-ui:')) {
throw new BadParameters(
`ZWaveJs-UI deviceFeature external_id is invalid: "${deviceFeature.external_id}" should starts with "zwavejs-ui:"`,
`ZWaveJs-UI deviceFeature external_id is invalid: "${gladysFeature.external_id}" should starts with "zwavejs-ui:"`,
);
}

const node = getNode(this.devices, device.external_id);
const node = this.getDevice(gladysDevice.external_id);
if (!node) {
throw new BadParameters(`ZWaveJs-UI node not found: "${device.external_id}".`);
throw new BadParameters(`ZWaveJs-UI Gladys node not found: "${gladysDevice.external_id}".`);
}

const nodeFeature = getNodeFeature(node, deviceFeature.external_id);
const zwaveJsNode = this.getZwaveJsDevice(node.external_id);
if (!zwaveJsNode) {
throw new BadParameters(`ZWaveJs-UI node not found: "${node.external_id}".`);
}

const nodeFeature = getNodeFeature(node, gladysFeature.external_id);
if (!nodeFeature) {
throw new BadParameters(`ZWaveJs-UI feature not found: "${deviceFeature.external_id}".`);
throw new BadParameters(`ZWaveJs-UI feature not found: "${gladysFeature.external_id}".`);
}

const command = getCommand(nodeFeature);
if (!command) {
// We do not manage this feature for writing
throw new BadParameters(`ZWaveJS-UI command not found: "${deviceFeature.external_id}"`);
const actionDescriptor = getAction(zwaveJsNode, nodeFeature);
if (!actionDescriptor) {
// We do not manage this feature for setValue
throw new BadParameters(`ZWaveJS-UI action not found: "${gladysFeature.external_id}"`);
}

const commandArgs = command.getArgs(value, nodeFeature);
if (commandArgs === null) {
throw new BadParameters(`ZWaveJS-UI command value not supported: "${value}"`);
const nodeContext = { node, nodeFeature, zwaveJsNode, gladysDevice, gladysFeature };
const action = actionDescriptor(value, nodeContext);
if (action.isCommand) {
// API sendCommand
// https://zwave-js.github.io/zwave-js-ui/#/guide/mqtt?id=sendcommand
const mqttPayload = {
args: [
{
nodeId: nodeFeature.node_id,
commandClass: nodeFeature.command_class,
endpoint: nodeFeature.endpoint,
},
action.name,
action.value,
],
};
this.publish('zwave/_CLIENTS/ZWAVE_GATEWAY-zwave-js-ui/api/sendCommand/set', JSON.stringify(mqttPayload));
} else {
// API writeValue
// https://zwave-js.github.io/zwave-js-ui/#/guide/mqtt?id=writevalue
const mqttPayload = {
args: [
{
nodeId: nodeFeature.node_id,
commandClass: nodeFeature.command_class,
endpoint: nodeFeature.endpoint,
property: action.name,
},
action.value,
],
};
this.publish('zwave/_CLIENTS/ZWAVE_GATEWAY-zwave-js-ui/api/writeValue/set', JSON.stringify(mqttPayload));
}

// https://zwave-js.github.io/zwave-js-ui/#/guide/mqtt?id=send-command
// https://zwave-js.github.io/zwave-js-ui/#/guide/mqtt?id=sendcommand
const mqttPayload = {
args: [
{
nodeId: nodeFeature.node_id,
commandClass: nodeFeature.command_class,
endpoint: nodeFeature.endpoint,
},
command.getName(nodeFeature),
commandArgs,
],
};
this.publish('zwave/_CLIENTS/ZWAVE_GATEWAY-zwave-js-ui/api/sendCommand/set', JSON.stringify(mqttPayload));
if (action.stateUpdate) {
await Promise.map(
action.stateUpdate,
async (stateUpdate) => {
const featureId = getDeviceFeatureId(
zwaveJsNode.id,
nodeFeature.command_class_name,
nodeFeature.endpoint,
stateUpdate.property_name || nodeFeature.property_name,
stateUpdate.property_name ? stateUpdate.property_key_name || '' : nodeFeature.property_key_name || '',
stateUpdate.feature_name || '',
);

return Promise.resolve();
// Only if the device has the expected feature, apply the local change
if (getNodeFeature(node, featureId)) {
const gladysUpdatedFeature = gladysDevice.features.find((f) => f.external_id === featureId);
await this.gladys.device.saveState(gladysUpdatedFeature, stateUpdate.value);
}
},
{ concurrency: 2 },
);
}
}

module.exports = {
Expand Down
6 changes: 6 additions & 0 deletions server/services/zwavejs-ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/services/zwavejs-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"arm64"
],
"dependencies": {
"bluebird": "^3.7.2",
"get-value": "^3.0.1",
"mqtt": "^4.0.0"
}
Expand Down
12 changes: 12 additions & 0 deletions server/services/zwavejs-ui/utils/cleanNames.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const cleanNames = (text) => {
if (!text || typeof text !== 'string') {
return '';
}
return text
.replaceAll(' ', '_')
.replaceAll('(', '')
.replaceAll(')', '')
.toLowerCase();
};

module.exports = cleanNames;
Loading

0 comments on commit 69f6d03

Please sign in to comment.