diff --git a/front/src/components/boxs/device-in-room/DeviceRow.jsx b/front/src/components/boxs/device-in-room/DeviceRow.jsx
index 7f631e4e1c..cbaab30e1d 100644
--- a/front/src/components/boxs/device-in-room/DeviceRow.jsx
+++ b/front/src/components/boxs/device-in-room/DeviceRow.jsx
@@ -14,6 +14,9 @@ import SetpointDeviceFeature from './device-features/SetpointDeviceFeature';
import AirConditioningModeDeviceFeature from './device-features/AirConditioningModeDeviceFeature';
import PilotWireModeDeviceFeature from './device-features/PilotWireModeDeviceFeature';
import LMHVolumeDeviceFeature from './device-features/LMHVolumeDeviceFeature';
+import MultiLevelWithInputDeviceFeature from './device-features/MultiLevelWithInputDeviceFeature';
+import SirenModeDeviceFeature from './device-features/SirenModeDeviceFeature';
+import SirenLevelDeviceFeature from './device-features/SirenLevelDeviceFeature';
import PushDeviceFeature from './device-features/PushDeviceFeature';
const ROW_TYPE_BY_FEATURE_TYPE = {
@@ -34,8 +37,14 @@ const ROW_TYPE_BY_FEATURE_TYPE = {
[DEVICE_FEATURE_TYPES.HEATER.PILOT_WIRE_MODE]: PilotWireModeDeviceFeature,
[DEVICE_FEATURE_TYPES.LOCK.BINARY]: BinaryDeviceFeature,
[DEVICE_FEATURE_TYPES.SIREN.LMH_VOLUME]: LMHVolumeDeviceFeature,
+ [DEVICE_FEATURE_TYPES.SIREN.MODE]: SirenModeDeviceFeature,
+ [DEVICE_FEATURE_TYPES.SIREN.LEVEL]: SirenLevelDeviceFeature,
+ [DEVICE_FEATURE_TYPES.SIREN.STROBE_LEVEL]: SirenLevelDeviceFeature,
+ [DEVICE_FEATURE_TYPES.SIREN.STROBE]: BinaryDeviceFeature,
+ [DEVICE_FEATURE_TYPES.SIREN.STROBE_DUTY_CYCLE]: NumberDeviceFeature,
[DEVICE_FEATURE_TYPES.SIREN.MELODY]: NumberDeviceFeature,
- [DEVICE_FEATURE_TYPES.DURATION.DECIMAL]: MultiLevelDeviceFeature,
+ [DEVICE_FEATURE_TYPES.SIREN.VOLUME]: MultiLevelDeviceFeature,
+ [DEVICE_FEATURE_TYPES.DURATION.DECIMAL]: MultiLevelWithInputDeviceFeature,
[DEVICE_FEATURE_TYPES.BUTTON.PUSH]: PushDeviceFeature,
[DEVICE_FEATURE_TYPES.SWITCH.TARGET_CURRENT]: SetpointDeviceFeature,
[DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_CHARGE.CHARGE_ON]: BinaryDeviceFeature,
diff --git a/front/src/components/boxs/device-in-room/SupportedFeatureTypes.jsx b/front/src/components/boxs/device-in-room/SupportedFeatureTypes.jsx
index 4167dbae47..46dd108a9f 100644
--- a/front/src/components/boxs/device-in-room/SupportedFeatureTypes.jsx
+++ b/front/src/components/boxs/device-in-room/SupportedFeatureTypes.jsx
@@ -18,6 +18,7 @@ const SUPPORTED_FEATURE_TYPES = [
DEVICE_FEATURE_TYPES.LOCK.STATE,
DEVICE_FEATURE_TYPES.HEATER.PILOT_WIRE_MODE,
DEVICE_FEATURE_TYPES.SIREN.LMH_VOLUME,
+ DEVICE_FEATURE_TYPES.SIREN.VOLUME,
DEVICE_FEATURE_TYPES.SIREN.MELODY,
DEVICE_FEATURE_TYPES.DURATION.DECIMAL,
DEVICE_FEATURE_TYPES.BUTTON.PUSH,
diff --git a/front/src/components/boxs/device-in-room/device-features/MultiLevelWithInputDeviceFeature.jsx b/front/src/components/boxs/device-in-room/device-features/MultiLevelWithInputDeviceFeature.jsx
new file mode 100644
index 0000000000..651083416c
--- /dev/null
+++ b/front/src/components/boxs/device-in-room/device-features/MultiLevelWithInputDeviceFeature.jsx
@@ -0,0 +1,96 @@
+import { Text } from 'preact-i18n';
+import { useState, useEffect } from 'preact/hooks';
+import get from 'get-value';
+import cx from 'classnames';
+
+import { DeviceFeatureCategoriesIcon } from '../../../../utils/consts';
+
+import style from './style.css';
+
+const MultiLevelWithInputDeviceFeature = ({ children, ...props }) => {
+ const { deviceFeature } = props;
+ const [localValue, setLocalValue] = useState(deviceFeature.last_value);
+
+ useEffect(() => {
+ setLocalValue(deviceFeature.last_value);
+ }, [deviceFeature.last_value]);
+
+ const clamp = value => {
+ let v = Number(value);
+ if (Number.isNaN(v)) {
+ return deviceFeature.min;
+ }
+ if (deviceFeature.min !== undefined && v < deviceFeature.min) v = deviceFeature.min;
+ if (deviceFeature.max !== undefined && v > deviceFeature.max) v = deviceFeature.max;
+ return v;
+ };
+
+ const handleSlider = e => {
+ const v = e.target.value;
+ setLocalValue(v);
+ props.updateValueWithDebounce(deviceFeature, v);
+ };
+
+ const handleInput = e => {
+ setLocalValue(e.target.value);
+ };
+
+ const commitInput = () => {
+ const v = clamp(localValue);
+ setLocalValue(v);
+ props.updateValueWithDebounce(deviceFeature, v);
+ };
+
+ const handleKeyDown = e => {
+ if (e.key === 'Enter') {
+ e.target.blur();
+ }
+ };
+
+ return (
+
diff --git a/front/src/utils/consts.js b/front/src/utils/consts.js
index 14c465d53a..a64b1ed35a 100644
--- a/front/src/utils/consts.js
+++ b/front/src/utils/consts.js
@@ -234,7 +234,13 @@ export const DeviceFeatureCategoriesIcon = {
[DEVICE_FEATURE_CATEGORIES.SIREN]: {
[DEVICE_FEATURE_TYPES.SIREN.BINARY]: 'bell',
[DEVICE_FEATURE_TYPES.SIREN.LMH_VOLUME]: 'volume-1',
- [DEVICE_FEATURE_TYPES.SIREN.MELODY]: 'music'
+ [DEVICE_FEATURE_TYPES.SIREN.MELODY]: 'music',
+ [DEVICE_FEATURE_TYPES.SIREN.MODE]: 'alert-triangle',
+ [DEVICE_FEATURE_TYPES.SIREN.LEVEL]: 'volume-2',
+ [DEVICE_FEATURE_TYPES.SIREN.STROBE]: 'zap',
+ [DEVICE_FEATURE_TYPES.SIREN.STROBE_LEVEL]: 'zap',
+ [DEVICE_FEATURE_TYPES.SIREN.STROBE_DUTY_CYCLE]: 'clock',
+ [DEVICE_FEATURE_TYPES.SIREN.VOLUME]: 'volume-2'
},
[DEVICE_FEATURE_CATEGORIES.TAMPER]: {
[DEVICE_FEATURE_TYPES.SENSOR.BINARY]: 'shield'
@@ -482,6 +488,9 @@ export const DeviceFeatureCategoriesIcon = {
[DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.ODOMETER]: 'trending-up',
[DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.TIRE_PRESSURE]: 'disc',
[DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.WINDOW_OPENED]: 'square'
+ },
+ [DEVICE_FEATURE_CATEGORIES.AC_CONNECTED]: {
+ [DEVICE_FEATURE_TYPES.AC_CONNECTED.BINARY]: 'zap'
}
};
diff --git a/server/services/zigbee2mqtt/exposes/binaryType.js b/server/services/zigbee2mqtt/exposes/binaryType.js
index 05d90c9334..6776da9fc8 100644
--- a/server/services/zigbee2mqtt/exposes/binaryType.js
+++ b/server/services/zigbee2mqtt/exposes/binaryType.js
@@ -1,6 +1,12 @@
const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../utils/constants');
const names = {
+ ac_connected: {
+ feature: {
+ category: DEVICE_FEATURE_CATEGORIES.AC_CONNECTED,
+ type: DEVICE_FEATURE_TYPES.AC_CONNECTED.BINARY,
+ },
+ },
alarm: {
feature: {
category: DEVICE_FEATURE_CATEGORIES.SIREN,
@@ -108,6 +114,14 @@ const names = {
type: DEVICE_FEATURE_TYPES.INPUT.BINARY,
},
},
+ strobe: {
+ types: {
+ composite: {
+ category: DEVICE_FEATURE_CATEGORIES.SIREN,
+ type: DEVICE_FEATURE_TYPES.SIREN.STROBE,
+ },
+ },
+ },
};
module.exports = {
diff --git a/server/services/zigbee2mqtt/exposes/compositeType.js b/server/services/zigbee2mqtt/exposes/compositeType.js
index 2eb5bd4eda..b905e96f6d 100644
--- a/server/services/zigbee2mqtt/exposes/compositeType.js
+++ b/server/services/zigbee2mqtt/exposes/compositeType.js
@@ -4,11 +4,17 @@ const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../ut
module.exports = {
type: 'composite',
writeValue: (expose, value) => {
- const [r, g, b] = intToRgb(parseInt(value, 10));
- return { rgb: `${r},${g},${b}` };
+ if (expose && expose.name === 'color_xy') {
+ const [r, g, b] = intToRgb(parseInt(value, 10));
+ return { rgb: `${r},${g},${b}` };
+ }
+ return value;
},
readValue: (expose, value) => {
- return xyToInt(value.x, value.y);
+ if (expose && expose.name === 'color_xy') {
+ return xyToInt(value.x, value.y);
+ }
+ return value;
},
names: {
color_xy: {
diff --git a/server/services/zigbee2mqtt/exposes/enumType.js b/server/services/zigbee2mqtt/exposes/enumType.js
index b6755c42eb..4ce80ea4e9 100644
--- a/server/services/zigbee2mqtt/exposes/enumType.js
+++ b/server/services/zigbee2mqtt/exposes/enumType.js
@@ -4,6 +4,7 @@ const {
BUTTON_STATUS,
COVER_STATE,
SIREN_LMH_VOLUME,
+ SIREN_MODE,
PILOT_WIRE_MODE,
LIQUID_STATE,
} = require('../../../utils/constants');
@@ -136,6 +137,24 @@ addMapping('liquid_state', LIQUID_STATE.LOW, 'low');
addMapping('liquid_state', LIQUID_STATE.NORMAL, 'normal');
addMapping('liquid_state', LIQUID_STATE.HIGH, 'high');
+addMapping('mode', SIREN_MODE.STOP, 'stop');
+addMapping('mode', SIREN_MODE.BURGLAR, 'burglar');
+addMapping('mode', SIREN_MODE.FIRE, 'fire');
+addMapping('mode', SIREN_MODE.EMERGENCY, 'emergency');
+addMapping('mode', SIREN_MODE.POLICE_PANIC, 'police_panic');
+addMapping('mode', SIREN_MODE.FIRE_PANIC, 'fire_panic');
+addMapping('mode', SIREN_MODE.EMERGENCY_PANIC, 'emergency_panic');
+
+addMapping('level', SIREN_LMH_VOLUME.LOW, 'low');
+addMapping('level', SIREN_LMH_VOLUME.MEDIUM, 'medium');
+addMapping('level', SIREN_LMH_VOLUME.HIGH, 'high');
+addMapping('level', SIREN_LMH_VOLUME.VERY_HIGH, 'very_high');
+
+addMapping('strobe_level', SIREN_LMH_VOLUME.LOW, 'low');
+addMapping('strobe_level', SIREN_LMH_VOLUME.MEDIUM, 'medium');
+addMapping('strobe_level', SIREN_LMH_VOLUME.HIGH, 'high');
+addMapping('strobe_level', SIREN_LMH_VOLUME.VERY_HIGH, 'very_high');
+
module.exports = {
type: 'enum',
writeValue: (expose, value) => {
@@ -194,6 +213,36 @@ module.exports = {
type: DEVICE_FEATURE_TYPES.SIREN.MELODY,
},
},
+ mode: {
+ types: {
+ composite: {
+ category: DEVICE_FEATURE_CATEGORIES.SIREN,
+ type: DEVICE_FEATURE_TYPES.SIREN.MODE,
+ min: 0,
+ max: 6,
+ },
+ },
+ },
+ level: {
+ types: {
+ composite: {
+ category: DEVICE_FEATURE_CATEGORIES.SIREN,
+ type: DEVICE_FEATURE_TYPES.SIREN.LEVEL,
+ min: 0,
+ max: 3,
+ },
+ },
+ },
+ strobe_level: {
+ types: {
+ composite: {
+ category: DEVICE_FEATURE_CATEGORIES.SIREN,
+ type: DEVICE_FEATURE_TYPES.SIREN.STROBE_LEVEL,
+ min: 0,
+ max: 3,
+ },
+ },
+ },
pilot_wire_mode: {
feature: {
category: DEVICE_FEATURE_CATEGORIES.HEATER,
diff --git a/server/services/zigbee2mqtt/exposes/numericType.js b/server/services/zigbee2mqtt/exposes/numericType.js
index bb3038619e..ff23a4c78c 100644
--- a/server/services/zigbee2mqtt/exposes/numericType.js
+++ b/server/services/zigbee2mqtt/exposes/numericType.js
@@ -22,6 +22,25 @@ module.exports = {
category: DEVICE_FEATURE_CATEGORIES.DURATION,
type: DEVICE_FEATURE_TYPES.DURATION.DECIMAL,
unit: DEVICE_FEATURE_UNITS.SECONDS,
+ min: 0,
+ max: 3600,
+ },
+ types: {
+ composite: {
+ category: DEVICE_FEATURE_CATEGORIES.DURATION,
+ type: DEVICE_FEATURE_TYPES.DURATION.DECIMAL,
+ unit: DEVICE_FEATURE_UNITS.SECONDS,
+ },
+ },
+ },
+ strobe_duty_cycle: {
+ types: {
+ composite: {
+ category: DEVICE_FEATURE_CATEGORIES.SIREN,
+ type: DEVICE_FEATURE_TYPES.SIREN.STROBE_DUTY_CYCLE,
+ min: 0,
+ max: 10,
+ },
},
},
battery: {
@@ -1062,5 +1081,13 @@ module.exports = {
max: 100,
},
},
+ volume: {
+ feature: {
+ category: DEVICE_FEATURE_CATEGORIES.SIREN,
+ type: DEVICE_FEATURE_TYPES.SIREN.VOLUME,
+ min: 0,
+ max: 100,
+ },
+ },
},
};
diff --git a/server/services/zigbee2mqtt/lib/findMatchingExpose.js b/server/services/zigbee2mqtt/lib/findMatchingExpose.js
index ff217a141b..668004b6e6 100644
--- a/server/services/zigbee2mqtt/lib/findMatchingExpose.js
+++ b/server/services/zigbee2mqtt/lib/findMatchingExpose.js
@@ -1,14 +1,16 @@
const logger = require('../../../utils/logger');
-const recursiveSearch = (expose, type, search) => {
+const recursiveSearch = (expose, type, search, parent = undefined) => {
const { property, features = [] } = expose;
if (property === search) {
- return expose;
+ return { expose, parent };
}
+ const currentParent = expose.type === 'composite' ? expose : parent;
+
for (let i = 0; i < features.length; i += 1) {
const feature = features[i];
- const result = recursiveSearch(feature, type || feature.type, search);
+ const result = recursiveSearch(feature, type || feature.type, search, currentParent);
if (result) {
return result;
}
@@ -35,12 +37,12 @@ function findMatchingExpose(deviceName, property) {
// Looks for matching "expose" or sub-feature
const { exposes } = discoveredDevice.definition;
- const expose = recursiveSearch({ features: exposes }, undefined, property);
- if (!expose) {
+ const result = recursiveSearch({ features: exposes }, undefined, property);
+ if (!result) {
logger.debug(`Exposed property "${property}" on device "${deviceName}" not found`);
}
- return expose;
+ return result;
}
module.exports = {
diff --git a/server/services/zigbee2mqtt/lib/handleMqttMessage.js b/server/services/zigbee2mqtt/lib/handleMqttMessage.js
index f18455bd27..66886cbe9c 100644
--- a/server/services/zigbee2mqtt/lib/handleMqttMessage.js
+++ b/server/services/zigbee2mqtt/lib/handleMqttMessage.js
@@ -97,6 +97,27 @@ async function handleMqttMessage(topic, message) {
Object.keys(incomingFeatures).forEach((zigbeeFeatureField) => {
// Find the feature regarding the field name
const value = incomingFeatures[zigbeeFeatureField];
+
+ // Handle composite object values (e.g. {"warning": {"mode": "burglar", "level": "high"}})
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
+ Object.keys(value).forEach((subField) => {
+ const subValue = value[subField];
+ const subFeature = convertFeature(device.features, subField, subValue);
+ if (subFeature) {
+ try {
+ const newState = {
+ device_feature_external_id: `${subFeature.external_id}`,
+ state: this.readValue(deviceName, subField, subValue),
+ };
+ this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, newState);
+ } catch (e) {
+ logger.error(`Failed to convert value for device ${deviceName}:`, e);
+ }
+ }
+ });
+ return;
+ }
+
const feature = convertFeature(device.features, zigbeeFeatureField, value);
if (feature) {
try {
diff --git a/server/services/zigbee2mqtt/lib/readValue.js b/server/services/zigbee2mqtt/lib/readValue.js
index fa4063ba50..a2bd499576 100644
--- a/server/services/zigbee2mqtt/lib/readValue.js
+++ b/server/services/zigbee2mqtt/lib/readValue.js
@@ -11,11 +11,12 @@ const exposesMap = require('../exposes');
*/
function readValue(deviceName, property, value) {
// Looks mapping from exposes
- const expose = this.findMatchingExpose(deviceName, property);
- if (!expose) {
+ const result = this.findMatchingExpose(deviceName, property);
+ if (!result) {
throw new Error(`Zigbee2mqqt expose not found on device "${deviceName}" with property "${property}".`);
}
+ const { expose } = result;
const matchingValue = exposesMap[expose.type].readValue(expose, value);
if (matchingValue === undefined) {
diff --git a/server/services/zigbee2mqtt/lib/setValue.js b/server/services/zigbee2mqtt/lib/setValue.js
index 44e5bae3e0..b56cf9003d 100644
--- a/server/services/zigbee2mqtt/lib/setValue.js
+++ b/server/services/zigbee2mqtt/lib/setValue.js
@@ -28,12 +28,19 @@ function setValue(device, deviceFeature, value) {
let zigbeeValue;
// Looks mapping from exposes
- const expose = this.findMatchingExpose(topic, property);
- if (expose) {
+ const result = this.findMatchingExpose(topic, property);
+ if (result) {
+ const { expose, parent } = result;
zigbeeValue = exposesMap[expose.type].writeValue(expose, value, featureIndex);
// Send message to Zigbee2mqtt topics
- const mqttPaylad = JSON.stringify({ [property]: zigbeeValue });
- this.mqttClient.publish(`zigbee2mqtt/${topic}/set`, mqttPaylad);
+ let mqttPayload;
+ if (parent && parent.property) {
+ // Composite sub-feature: wrap value inside parent property (e.g. {"warning": {"mode": value}})
+ mqttPayload = JSON.stringify({ [parent.property]: { [property]: zigbeeValue } });
+ } else {
+ mqttPayload = JSON.stringify({ [property]: zigbeeValue });
+ }
+ this.mqttClient.publish(`zigbee2mqtt/${topic}/set`, mqttPayload);
} else {
throw new BadParameters(`Zigbee2mqtt expose not found: "${externalId}" with property "${property}"`);
}
diff --git a/server/test/services/zigbee2mqtt/exposes/compositeType.test.js b/server/test/services/zigbee2mqtt/exposes/compositeType.test.js
index 366f9b284c..cde49a5118 100644
--- a/server/test/services/zigbee2mqtt/exposes/compositeType.test.js
+++ b/server/test/services/zigbee2mqtt/exposes/compositeType.test.js
@@ -3,13 +3,27 @@ const { assert } = require('chai');
const compositeType = require('../../../../services/zigbee2mqtt/exposes/compositeType');
describe('zigbee2mqtt compositeType', () => {
+ const colorExpose = { name: 'color_xy' };
+
it('should write color 16711680', () => {
- const result = compositeType.writeValue(null, 16711680);
+ const result = compositeType.writeValue(colorExpose, 16711680);
assert.deepEqual(result, { rgb: '255,0,0' });
});
it('should read color 16711680', () => {
- const result = compositeType.readValue(null, { x: 0.701, y: 0.299 });
+ const result = compositeType.readValue(colorExpose, { x: 0.701, y: 0.299 });
assert.equal(result, 16711680);
});
+
+ it('should pass through value for non-color composite (write)', () => {
+ const warningExpose = { name: 'warning' };
+ const result = compositeType.writeValue(warningExpose, 'burglar');
+ assert.equal(result, 'burglar');
+ });
+
+ it('should pass through value for non-color composite (read)', () => {
+ const warningExpose = { name: 'warning' };
+ const result = compositeType.readValue(warningExpose, 'burglar');
+ assert.equal(result, 'burglar');
+ });
});
diff --git a/server/test/services/zigbee2mqtt/exposes/warningLevelEnumType.test.js b/server/test/services/zigbee2mqtt/exposes/warningLevelEnumType.test.js
new file mode 100644
index 0000000000..0cef7ab2ac
--- /dev/null
+++ b/server/test/services/zigbee2mqtt/exposes/warningLevelEnumType.test.js
@@ -0,0 +1,56 @@
+const { assert } = require('chai');
+
+const enumType = require('../../../../services/zigbee2mqtt/exposes/enumType');
+const { SIREN_LMH_VOLUME } = require('../../../../utils/constants');
+
+describe('zigbee2mqtt warning level enumType', () => {
+ const expose = {
+ name: 'level',
+ values: ['low', 'medium', 'high', 'very_high'],
+ };
+
+ [
+ { enumValue: 'low', intValue: SIREN_LMH_VOLUME.LOW },
+ { enumValue: 'medium', intValue: SIREN_LMH_VOLUME.MEDIUM },
+ { enumValue: 'high', intValue: SIREN_LMH_VOLUME.HIGH },
+ { enumValue: 'very_high', intValue: SIREN_LMH_VOLUME.VERY_HIGH },
+ ].forEach((mapping) => {
+ const { enumValue, intValue } = mapping;
+
+ it(`should write ${enumValue} value as ${intValue} value`, () => {
+ const result = enumType.writeValue(expose, intValue);
+ assert.equal(result, enumValue);
+ });
+
+ it(`should read ${intValue} value as ${enumValue}`, () => {
+ const result = enumType.readValue(expose, enumValue);
+ assert.equal(result, intValue);
+ });
+ });
+});
+
+describe('zigbee2mqtt warning strobe_level enumType', () => {
+ const expose = {
+ name: 'strobe_level',
+ values: ['low', 'medium', 'high', 'very_high'],
+ };
+
+ [
+ { enumValue: 'low', intValue: SIREN_LMH_VOLUME.LOW },
+ { enumValue: 'medium', intValue: SIREN_LMH_VOLUME.MEDIUM },
+ { enumValue: 'high', intValue: SIREN_LMH_VOLUME.HIGH },
+ { enumValue: 'very_high', intValue: SIREN_LMH_VOLUME.VERY_HIGH },
+ ].forEach((mapping) => {
+ const { enumValue, intValue } = mapping;
+
+ it(`should write ${enumValue} value as ${intValue} value`, () => {
+ const result = enumType.writeValue(expose, intValue);
+ assert.equal(result, enumValue);
+ });
+
+ it(`should read ${intValue} value as ${enumValue}`, () => {
+ const result = enumType.readValue(expose, enumValue);
+ assert.equal(result, intValue);
+ });
+ });
+});
diff --git a/server/test/services/zigbee2mqtt/exposes/warningModeEnumType.test.js b/server/test/services/zigbee2mqtt/exposes/warningModeEnumType.test.js
new file mode 100644
index 0000000000..992140e6e1
--- /dev/null
+++ b/server/test/services/zigbee2mqtt/exposes/warningModeEnumType.test.js
@@ -0,0 +1,43 @@
+const { assert } = require('chai');
+
+const enumType = require('../../../../services/zigbee2mqtt/exposes/enumType');
+const { SIREN_MODE } = require('../../../../utils/constants');
+
+describe('zigbee2mqtt warning mode enumType', () => {
+ const expose = {
+ name: 'mode',
+ values: ['stop', 'burglar', 'fire', 'emergency', 'police_panic', 'fire_panic', 'emergency_panic'],
+ };
+
+ [
+ { enumValue: 'stop', intValue: SIREN_MODE.STOP },
+ { enumValue: 'burglar', intValue: SIREN_MODE.BURGLAR },
+ { enumValue: 'fire', intValue: SIREN_MODE.FIRE },
+ { enumValue: 'emergency', intValue: SIREN_MODE.EMERGENCY },
+ { enumValue: 'police_panic', intValue: SIREN_MODE.POLICE_PANIC },
+ { enumValue: 'fire_panic', intValue: SIREN_MODE.FIRE_PANIC },
+ { enumValue: 'emergency_panic', intValue: SIREN_MODE.EMERGENCY_PANIC },
+ ].forEach((mapping) => {
+ const { enumValue, intValue } = mapping;
+
+ it(`should write ${enumValue} value as ${intValue} value`, () => {
+ const result = enumType.writeValue(expose, intValue);
+ assert.equal(result, enumValue);
+ });
+
+ it(`should read ${intValue} value as ${enumValue}`, () => {
+ const result = enumType.readValue(expose, enumValue);
+ assert.equal(result, intValue);
+ });
+ });
+
+ it('should write undefined value on unknown int', () => {
+ const result = enumType.writeValue(expose, 99);
+ assert.equal(result, undefined);
+ });
+
+ it('should read undefined value on unknown string', () => {
+ const result = enumType.readValue(expose, 'unknown');
+ assert.equal(result, undefined);
+ });
+});
diff --git a/server/test/services/zigbee2mqtt/lib/findMatchingExpose.test.js b/server/test/services/zigbee2mqtt/lib/findMatchingExpose.test.js
index 4b45c85a08..e67302f236 100644
--- a/server/test/services/zigbee2mqtt/lib/findMatchingExpose.test.js
+++ b/server/test/services/zigbee2mqtt/lib/findMatchingExpose.test.js
@@ -3,6 +3,7 @@ const { assert } = require('chai');
const Zigbee2MqttService = require('../../../../services/zigbee2mqtt');
const discoveredDevice = require('./payloads/single_mqtt_device.json');
+const discoveredDevices = require('./payloads/mqtt_devices_get.json');
const gladys = {
job: {
@@ -34,7 +35,7 @@ describe('zigbee2mqtt findMatchingExpose', () => {
});
it('expose discovered', () => {
- const expected = {
+ const expectedExpose = {
type: 'binary',
name: 'state',
property: 'state',
@@ -43,6 +44,35 @@ describe('zigbee2mqtt findMatchingExpose', () => {
value_off: 'OFF',
};
const result = zigbee2MqttService.device.findMatchingExpose('0x00158d00045b2740', 'state');
- assert.deepEqual(result, expected);
+ assert.deepEqual(result.expose, expectedExpose);
+ assert.equal(result.parent, undefined);
+ });
+
+ it('expose discovered with parent on cover position', () => {
+ const result = zigbee2MqttService.device.findMatchingExpose('0x00158d00045b2740', 'position');
+ assert.equal(result.expose.property, 'position');
+ assert.equal(result.expose.type, 'numeric');
+ assert.equal(result.parent, undefined);
+ });
+
+ it('expose discovered with composite parent (warning mode)', () => {
+ const sirenDevice = discoveredDevices.find((d) => d.friendly_name === '0x00158d00045b2741');
+ zigbee2MqttService.device.discoveredDevices[sirenDevice.friendly_name] = sirenDevice;
+ const result = zigbee2MqttService.device.findMatchingExpose('0x00158d00045b2741', 'mode');
+ assert.equal(result.expose.property, 'mode');
+ assert.equal(result.expose.type, 'enum');
+ assert.isDefined(result.parent);
+ assert.equal(result.parent.property, 'warning');
+ assert.equal(result.parent.type, 'composite');
+ });
+
+ it('expose discovered with composite parent (warning strobe)', () => {
+ const sirenDevice = discoveredDevices.find((d) => d.friendly_name === '0x00158d00045b2741');
+ zigbee2MqttService.device.discoveredDevices[sirenDevice.friendly_name] = sirenDevice;
+ const result = zigbee2MqttService.device.findMatchingExpose('0x00158d00045b2741', 'strobe');
+ assert.equal(result.expose.property, 'strobe');
+ assert.equal(result.expose.type, 'binary');
+ assert.isDefined(result.parent);
+ assert.equal(result.parent.property, 'warning');
});
});
diff --git a/server/test/services/zigbee2mqtt/lib/getDiscoveredDevices.test.js b/server/test/services/zigbee2mqtt/lib/getDiscoveredDevices.test.js
index 6d7a43608d..2bf8795b36 100644
--- a/server/test/services/zigbee2mqtt/lib/getDiscoveredDevices.test.js
+++ b/server/test/services/zigbee2mqtt/lib/getDiscoveredDevices.test.js
@@ -45,6 +45,10 @@ describe('zigbee2mqtt getDiscoveredDevices', () => {
.onSecondCall()
.returns(expectedDevicesPayload[1])
.onThirdCall()
+ .returns(false)
+ .onCall(3)
+ .returns(false)
+ .onCall(4)
.returns(false);
discoveredDevices
@@ -93,6 +97,10 @@ describe('zigbee2mqtt getDiscoveredDevices', () => {
.onSecondCall()
.returns(expectedDevicesPayload[1])
.onThirdCall()
+ .returns(false)
+ .onCall(3)
+ .returns(false)
+ .onCall(4)
.returns(false);
discoveredDevices
diff --git a/server/test/services/zigbee2mqtt/lib/handleMqttMessage.test.js b/server/test/services/zigbee2mqtt/lib/handleMqttMessage.test.js
index ea1162f5b6..e157d8e5db 100644
--- a/server/test/services/zigbee2mqtt/lib/handleMqttMessage.test.js
+++ b/server/test/services/zigbee2mqtt/lib/handleMqttMessage.test.js
@@ -77,6 +77,10 @@ describe('zigbee2mqtt handleMqttMessage', () => {
.onSecondCall()
.returns(expectedDevicesPayload[1])
.onThirdCall()
+ .returns(null)
+ .onCall(3)
+ .returns(null)
+ .onCall(4)
.returns(null);
zigbee2mqttManager.gladys.stateManager.get = stateManagerGetStub;
// EXECUTE
@@ -259,6 +263,45 @@ describe('zigbee2mqtt handleMqttMessage', () => {
expect(zigbee2mqttManager.zigbee2mqttConnected).to.eq(true);
});
+ it('should get good topic with composite object value (warning)', async () => {
+ // PREPARE
+ stateManagerGetStub = sinon.stub();
+ stateManagerGetStub.onFirstCall().returns({
+ features: [
+ {
+ external_id: 'zigbee2mqtt:0x00158d00045b2741:siren:mode:mode',
+ type: 'mode',
+ },
+ {
+ external_id: 'zigbee2mqtt:0x00158d00045b2741:siren:level:level',
+ type: 'level',
+ },
+ ],
+ });
+ zigbee2mqttManager.gladys.stateManager.get = stateManagerGetStub;
+ zigbeeDevices
+ .filter((d) => d.supported)
+ .forEach((device) => {
+ zigbee2mqttManager.discoveredDevices[device.friendly_name] = device;
+ });
+ // EXECUTE
+ await zigbee2mqttManager.handleMqttMessage(
+ 'zigbee2mqtt/0x00158d00045b2741',
+ `{"warning": {"mode": "burglar", "level": "high"}}`,
+ );
+ // ASSERT
+ assert.calledTwice(gladys.event.emit);
+ assert.calledWithExactly(gladys.event.emit.firstCall, EVENTS.DEVICE.NEW_STATE, {
+ device_feature_external_id: 'zigbee2mqtt:0x00158d00045b2741:siren:mode:mode',
+ state: 1,
+ });
+ assert.calledWithExactly(gladys.event.emit.secondCall, EVENTS.DEVICE.NEW_STATE, {
+ device_feature_external_id: 'zigbee2mqtt:0x00158d00045b2741:siren:level:level',
+ state: 2,
+ });
+ expect(zigbee2mqttManager.zigbee2mqttConnected).to.eq(true);
+ });
+
it('should store backup', async () => {
zigbee2mqttManager.saveZ2mBackup = fake.resolves(true);
diff --git a/server/test/services/zigbee2mqtt/lib/payloads/event_device_result.json b/server/test/services/zigbee2mqtt/lib/payloads/event_device_result.json
index 036cef83a9..7d3b299abb 100644
--- a/server/test/services/zigbee2mqtt/lib/payloads/event_device_result.json
+++ b/server/test/services/zigbee2mqtt/lib/payloads/event_device_result.json
@@ -268,5 +268,98 @@
],
"should_poll": false,
"service_id": "f87b7af2-ca8e-44fc-b754-444354b42fee"
+ },
+ {
+ "name": "0x00158d00045b2741",
+ "model": "TS0216",
+ "external_id": "zigbee2mqtt:0x00158d00045b2741",
+ "features": [
+ {
+ "name": "Mode",
+ "read_only": false,
+ "has_feedback": true,
+ "min": 0,
+ "max": 7,
+ "category": "siren",
+ "type": "mode",
+ "unit": null,
+ "external_id": "zigbee2mqtt:0x00158d00045b2741:siren:mode:mode",
+ "selector": "zigbee2mqtt-0x00158d00045b2741-siren-mode-mode"
+ },
+ {
+ "name": "Level",
+ "read_only": false,
+ "has_feedback": true,
+ "min": 0,
+ "max": 4,
+ "category": "siren",
+ "type": "level",
+ "unit": null,
+ "external_id": "zigbee2mqtt:0x00158d00045b2741:siren:level:level",
+ "selector": "zigbee2mqtt-0x00158d00045b2741-siren-level-level"
+ },
+ {
+ "name": "Strobe",
+ "read_only": false,
+ "has_feedback": true,
+ "min": 0,
+ "max": 1,
+ "category": "siren",
+ "type": "strobe",
+ "unit": null,
+ "external_id": "zigbee2mqtt:0x00158d00045b2741:siren:strobe:strobe",
+ "selector": "zigbee2mqtt-0x00158d00045b2741-siren-strobe-strobe"
+ },
+ {
+ "name": "Strobe level",
+ "read_only": false,
+ "has_feedback": true,
+ "min": 0,
+ "max": 4,
+ "category": "siren",
+ "type": "strobe_level",
+ "unit": null,
+ "external_id": "zigbee2mqtt:0x00158d00045b2741:siren:strobe_level:strobe_level",
+ "selector": "zigbee2mqtt-0x00158d00045b2741-siren-strobe-level-strobe-level"
+ },
+ {
+ "name": "Strobe duty cycle",
+ "read_only": false,
+ "has_feedback": true,
+ "min": 0,
+ "max": 10,
+ "category": "siren",
+ "type": "strobe_duty_cycle",
+ "unit": null,
+ "external_id": "zigbee2mqtt:0x00158d00045b2741:siren:strobe_duty_cycle:strobe_duty_cycle",
+ "selector": "zigbee2mqtt-0x00158d00045b2741-siren-strobe-duty-cycle-strobe-duty-cycle"
+ },
+ {
+ "name": "Duration",
+ "read_only": false,
+ "has_feedback": true,
+ "min": 0,
+ "max": 600,
+ "category": "duration",
+ "type": "decimal",
+ "unit": "seconds",
+ "external_id": "zigbee2mqtt:0x00158d00045b2741:duration:decimal:duration",
+ "selector": "zigbee2mqtt-0x00158d00045b2741-duration-decimal-duration"
+ },
+ {
+ "category": "signal",
+ "external_id": "zigbee2mqtt:0x00158d00045b2741:signal:integer:linkquality",
+ "has_feedback": false,
+ "max": 5,
+ "min": 0,
+ "name": "Linkquality",
+ "read_only": true,
+ "selector": "zigbee2mqtt-0x00158d00045b2741-signal-integer-linkquality",
+ "type": "integer",
+ "unit": null
+ }
+ ],
+ "should_poll": false,
+ "service_id": "f87b7af2-ca8e-44fc-b754-444354b42fee"
}
]
diff --git a/server/test/services/zigbee2mqtt/lib/payloads/mqtt_devices_get.json b/server/test/services/zigbee2mqtt/lib/payloads/mqtt_devices_get.json
index f40dafffa7..0c9a068fe0 100644
--- a/server/test/services/zigbee2mqtt/lib/payloads/mqtt_devices_get.json
+++ b/server/test/services/zigbee2mqtt/lib/payloads/mqtt_devices_get.json
@@ -320,6 +320,106 @@
"supported": true,
"type": "EndDevice"
},
+ {
+ "date_code": "20200101",
+ "definition": {
+ "description": "Siren with warning composite",
+ "exposes": [
+ {
+ "type": "composite",
+ "name": "warning",
+ "property": "warning",
+ "features": [
+ {
+ "access": 7,
+ "description": "Mode of the warning",
+ "name": "mode",
+ "property": "mode",
+ "type": "enum",
+ "values": ["stop", "burglar", "fire", "emergency", "police_panic", "fire_panic", "emergency_panic"]
+ },
+ {
+ "access": 7,
+ "description": "Sound level",
+ "name": "level",
+ "property": "level",
+ "type": "enum",
+ "values": ["low", "medium", "high", "very_high"]
+ },
+ {
+ "access": 7,
+ "description": "Turn on/off the strobe",
+ "name": "strobe",
+ "property": "strobe",
+ "type": "binary",
+ "value_on": true,
+ "value_off": false
+ },
+ {
+ "access": 7,
+ "description": "Strobe light level",
+ "name": "strobe_level",
+ "property": "strobe_level",
+ "type": "enum",
+ "values": ["low", "medium", "high", "very_high"]
+ },
+ {
+ "access": 7,
+ "description": "Strobe duty cycle",
+ "name": "strobe_duty_cycle",
+ "property": "strobe_duty_cycle",
+ "type": "numeric",
+ "value_min": 0,
+ "value_max": 10
+ },
+ {
+ "access": 7,
+ "description": "Duration in seconds",
+ "name": "duration",
+ "property": "duration",
+ "type": "numeric",
+ "unit": "s",
+ "value_min": 0,
+ "value_max": 600
+ }
+ ]
+ },
+ {
+ "access": 1,
+ "description": "Link quality (signal strength)",
+ "name": "linkquality",
+ "property": "linkquality",
+ "type": "numeric",
+ "unit": "lqi",
+ "value_max": 255,
+ "value_min": 0
+ }
+ ],
+ "model": "TS0216",
+ "supports_ota": false,
+ "vendor": "TuYa"
+ },
+ "endpoints": {
+ "1": {
+ "bindings": [],
+ "clusters": {
+ "input": ["genBasic", "ssIasWd"],
+ "output": []
+ },
+ "configured_reportings": []
+ }
+ },
+ "friendly_name": "0x00158d00045b2741",
+ "ieee_address": "0x00158d00045b2741",
+ "interview_completed": true,
+ "interviewing": false,
+ "manufacturer": "TuYa",
+ "model_id": "TS0216",
+ "network_address": 23008,
+ "power_source": "Mains (single phase)",
+ "supported": true,
+ "type": "Router"
+ },
{
"date_code": "20181129",
"definition": null,
diff --git a/server/test/services/zigbee2mqtt/lib/readValue.test.js b/server/test/services/zigbee2mqtt/lib/readValue.test.js
index b05e3233b4..ae14062ff4 100644
--- a/server/test/services/zigbee2mqtt/lib/readValue.test.js
+++ b/server/test/services/zigbee2mqtt/lib/readValue.test.js
@@ -60,4 +60,19 @@ describe('zigbee2mqtt readValue', () => {
const result = zigbee2MqttService.device.readValue('0x00158d00045b2740', 'alarm', false);
assert.deepEqual(result, 0);
});
+
+ it('should return warning mode burglar as 1', () => {
+ const result = zigbee2MqttService.device.readValue('0x00158d00045b2741', 'mode', 'burglar');
+ assert.deepEqual(result, 1);
+ });
+
+ it('should return warning level high as 2', () => {
+ const result = zigbee2MqttService.device.readValue('0x00158d00045b2741', 'level', 'high');
+ assert.deepEqual(result, 2);
+ });
+
+ it('should return warning strobe true as 1', () => {
+ const result = zigbee2MqttService.device.readValue('0x00158d00045b2741', 'strobe', true);
+ assert.deepEqual(result, 1);
+ });
});
diff --git a/server/test/services/zigbee2mqtt/lib/setValue.test.js b/server/test/services/zigbee2mqtt/lib/setValue.test.js
index 5a5d94c93b..3fef98c76f 100644
--- a/server/test/services/zigbee2mqtt/lib/setValue.test.js
+++ b/server/test/services/zigbee2mqtt/lib/setValue.test.js
@@ -41,6 +41,21 @@ const featureColor = {
type: 'color',
};
+const featureWarningMode = {
+ external_id: 'zigbee2mqtt:0x00158d00045b2741:siren:mode:mode',
+ type: 'mode',
+};
+
+const featureWarningLevel = {
+ external_id: 'zigbee2mqtt:0x00158d00045b2741:siren:level:level',
+ type: 'level',
+};
+
+const featureWarningStrobe = {
+ external_id: 'zigbee2mqtt:0x00158d00045b2741:siren:strobe:strobe',
+ type: 'binary',
+};
+
describe('zigbee2mqtt setValue', () => {
// PREPARE
let zigbee2MqttManager;
@@ -131,4 +146,34 @@ describe('zigbee2mqtt setValue', () => {
JSON.stringify({ alarm: false }),
);
});
+
+ it('set value warning mode (composite sub-feature)', async () => {
+ // EXECUTE
+ await zigbee2MqttManager.setValue(null, featureWarningMode, 1);
+ assert.calledOnceWithExactly(
+ mqttClient.publish,
+ `zigbee2mqtt/0x00158d00045b2741/set`,
+ JSON.stringify({ warning: { mode: 'burglar' } }),
+ );
+ });
+
+ it('set value warning level (composite sub-feature)', async () => {
+ // EXECUTE
+ await zigbee2MqttManager.setValue(null, featureWarningLevel, 2);
+ assert.calledOnceWithExactly(
+ mqttClient.publish,
+ `zigbee2mqtt/0x00158d00045b2741/set`,
+ JSON.stringify({ warning: { level: 'high' } }),
+ );
+ });
+
+ it('set value warning strobe (composite sub-feature)', async () => {
+ // EXECUTE
+ await zigbee2MqttManager.setValue(null, featureWarningStrobe, 1);
+ assert.calledOnceWithExactly(
+ mqttClient.publish,
+ `zigbee2mqtt/0x00158d00045b2741/set`,
+ JSON.stringify({ warning: { strobe: true } }),
+ );
+ });
});
diff --git a/server/utils/constants.js b/server/utils/constants.js
index 02898ae9da..9eb0e3d811 100644
--- a/server/utils/constants.js
+++ b/server/utils/constants.js
@@ -113,6 +113,17 @@ const SIREN_LMH_VOLUME = {
LOW: 0,
MEDIUM: 1,
HIGH: 2,
+ VERY_HIGH: 3,
+};
+
+const SIREN_MODE = {
+ STOP: 0,
+ BURGLAR: 1,
+ FIRE: 2,
+ EMERGENCY: 3,
+ POLICE_PANIC: 4,
+ FIRE_PANIC: 5,
+ EMERGENCY_PANIC: 6,
};
const AC_MODE = {
@@ -520,6 +531,7 @@ const INTENTS = {
};
const DEVICE_FEATURE_CATEGORIES = {
+ AC_CONNECTED: 'ac-connected',
CHILD_LOCK: 'child-lock',
AIRQUALITY_SENSOR: 'airquality-sensor',
AIR_CONDITIONING: 'air-conditioning',
@@ -611,6 +623,9 @@ const DEVICE_FEATURE_TYPES = {
PUSH: 'push',
UNKNOWN: 'unknown',
},
+ AC_CONNECTED: {
+ BINARY: 'binary',
+ },
TEMPERATURE_SENSOR: {
MIN: 'min',
MAX: 'max',
@@ -638,6 +653,12 @@ const DEVICE_FEATURE_TYPES = {
BINARY: 'binary',
LMH_VOLUME: 'lmh_volume',
MELODY: 'melody',
+ MODE: 'mode',
+ LEVEL: 'level',
+ STROBE: 'strobe',
+ STROBE_LEVEL: 'strobe_level',
+ STROBE_DUTY_CYCLE: 'strobe_duty_cycle',
+ VOLUME: 'volume',
},
CHILD_LOCK: {
BINARY: 'binary',
@@ -1540,6 +1561,7 @@ module.exports.BUTTON_STATUS = BUTTON_STATUS;
module.exports.COVER_STATE = COVER_STATE;
module.exports.LOCK = LOCK;
module.exports.SIREN_LMH_VOLUME = SIREN_LMH_VOLUME;
+module.exports.SIREN_MODE = SIREN_MODE;
module.exports.AC_MODE = AC_MODE;
module.exports.PILOT_WIRE_MODE = PILOT_WIRE_MODE;
module.exports.LIQUID_STATE = LIQUID_STATE;