Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Zwave] Multilevel switch support (Dimmer and Curtains) #2061

Merged
merged 43 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
2efa7b3
Exposes multilevel switch
Apr 26, 2024
a8d300b
Manage deviceClass property to handle device type and specific features
Apr 26, 2024
ad0fb9a
Manage multilevel switch for lights ON/OFF
Apr 27, 2024
83e65ca
Turn on the multilevel uses startLevelChange to go back to the previo…
Apr 28, 2024
60f955d
Revert startLevelChange: it doesn't work. Need more works to get the …
Apr 28, 2024
0472b37
Manage nodeValueUpdated
Apr 28, 2024
8c88394
Move from light to switch
Apr 28, 2024
cbc9ea1
Remove hack for ON/OFF on default multilevel switch. Better to handle…
Apr 28, 2024
e2230d4
Support COVER_STATE commands
Apr 28, 2024
beb371f
Managing multiple features from one property
Apr 28, 2024
cffb9db
Fix state update on multiple switch default deviceClass
Apr 28, 2024
137ffcc
Revert const changes
Apr 28, 2024
9d234df
MultilevelSwitch : Power specific deviceClass : synchronize states
Apr 28, 2024
3d40d53
Command: be more explicit
Apr 28, 2024
a4be778
Fix curtains support
Apr 28, 2024
2fba527
Prettier
Apr 28, 2024
ada2ecb
Fix lint
Apr 28, 2024
4aa468f
Prettier
Apr 28, 2024
704e027
Manage state update for non curtains devices
Apr 28, 2024
0f6c6e4
Prettier
Apr 28, 2024
8e07158
Revert useless change
Apr 28, 2024
956ef62
Typo
Apr 28, 2024
50b1bc6
Update tests for onNewDeviceDiscover
Apr 28, 2024
b0a22aa
Fix tests
Apr 28, 2024
f55b6e7
Implementing tests
Apr 28, 2024
87bafd8
Rewrite to improve codeCoverage
Apr 28, 2024
23e8108
More code coverage
Apr 28, 2024
fa3bd0e
Fix coverage
Apr 28, 2024
0d6f3a8
setValue: use device and deviceFeature already sent by caller
Apr 29, 2024
922a6c3
Revert "setValue: use device and deviceFeature already sent by caller"
Apr 29, 2024
3fa2b41
Send all the context
Apr 29, 2024
bc1a8ba
Improve tests
Apr 29, 2024
bccde61
Change how we communicate with zWave to have even more control. Imple…
Apr 29, 2024
ee713b1
Refacto so we can use both command API and writeValue API
Apr 29, 2024
dee3448
Update tests + prettier
Apr 29, 2024
cb6b27b
Just to force Github to rerun
Apr 29, 2024
41b949f
Revert to command. Just a matter of taste...
Apr 29, 2024
65a8e2b
Makes restorePrevious explicit and let the user decide
May 4, 2024
291c4f8
Missing test: sync restorePrevious state on zWave value
May 4, 2024
8c43bff
Inverse Curtains positions
May 5, 2024
b1fba25
Special Multilevel Case: do not expose the native Binary Switch.
May 5, 2024
77a8641
Fix PR feedbacks
May 11, 2024
3a75f91
Precise concurrency for Promise.map
May 12, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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;