diff --git a/front/src/components/boxs/device-in-room/DeviceRow.jsx b/front/src/components/boxs/device-in-room/DeviceRow.jsx index 7f631e4e1c..842cf68e5f 100644 --- a/front/src/components/boxs/device-in-room/DeviceRow.jsx +++ b/front/src/components/boxs/device-in-room/DeviceRow.jsx @@ -14,6 +14,8 @@ 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 SirenModeDeviceFeature from './device-features/SirenModeDeviceFeature'; +import SirenLevelDeviceFeature from './device-features/SirenLevelDeviceFeature'; import PushDeviceFeature from './device-features/PushDeviceFeature'; const ROW_TYPE_BY_FEATURE_TYPE = { @@ -34,6 +36,11 @@ 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.BUTTON.PUSH]: PushDeviceFeature, diff --git a/front/src/components/boxs/device-in-room/device-features/SirenLevelDeviceFeature.jsx b/front/src/components/boxs/device-in-room/device-features/SirenLevelDeviceFeature.jsx new file mode 100644 index 0000000000..164cf8389e --- /dev/null +++ b/front/src/components/boxs/device-in-room/device-features/SirenLevelDeviceFeature.jsx @@ -0,0 +1,46 @@ +import get from 'get-value'; +import { Text } from 'preact-i18n'; + +import { DeviceFeatureCategoriesIcon } from '../../../../utils/consts'; +import { SIREN_LMH_VOLUME } from '../../../../../../server/utils/constants'; + +const SirenLevelDeviceFeature = ({ children, ...props }) => { + const { deviceFeature } = props; + const { category, type } = deviceFeature; + + function updateValue(e) { + props.updateValueWithDebounce(deviceFeature, e.currentTarget.value); + } + + return ( + + + + + {props.rowName} + + +
+
+ +
+
+ + + ); +}; + +export default SirenLevelDeviceFeature; diff --git a/front/src/components/boxs/device-in-room/device-features/SirenModeDeviceFeature.jsx b/front/src/components/boxs/device-in-room/device-features/SirenModeDeviceFeature.jsx new file mode 100644 index 0000000000..56571893c6 --- /dev/null +++ b/front/src/components/boxs/device-in-room/device-features/SirenModeDeviceFeature.jsx @@ -0,0 +1,55 @@ +import get from 'get-value'; +import { Text } from 'preact-i18n'; + +import { DeviceFeatureCategoriesIcon } from '../../../../utils/consts'; +import { SIREN_MODE } from '../../../../../../server/utils/constants'; + +const SirenModeDeviceFeature = ({ children, ...props }) => { + const { deviceFeature } = props; + const { category, type } = deviceFeature; + + function updateValue(e) { + props.updateValueWithDebounce(deviceFeature, e.currentTarget.value); + } + + return ( + + + + + {props.rowName} + + +
+
+ +
+
+ + + ); +}; + +export default SirenModeDeviceFeature; diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index c2d955661b..d217d13c82 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -3310,6 +3310,27 @@ "low": "Niedrig", "medium": "Mittel", "high": "Hoch" + }, + "mode": { + "stop": "Stopp", + "burglar": "Einbruch", + "fire": "Feuer", + "emergency": "Notfall", + "police_panic": "Polizei-Panik", + "fire_panic": "Feuer-Panik", + "emergency_panic": "Notfall-Panik" + }, + "level": { + "low": "Niedrig", + "medium": "Mittel", + "high": "Hoch", + "very_high": "Sehr hoch" + }, + "strobe_level": { + "low": "Niedrig", + "medium": "Mittel", + "high": "Hoch", + "very_high": "Sehr hoch" } } } @@ -3517,6 +3538,10 @@ } }, "deviceFeatureCategory": { + "ac-connected": { + "shortCategoryName": "Netzbetrieb", + "binary": "Netzstrom verbunden (ja/nein)" + }, "light": { "shortCategoryName": "Licht", "binary": "Licht: ein/aus", @@ -3622,7 +3647,12 @@ "shortCategoryName": "Sirene", "binary": "Sirene", "lmh_volume": "Lautstärke der Sirene", - "melody": "Melodie" + "melody": "Melodie", + "mode": "Warnmodus", + "level": "Lautstärke", + "strobe": "Blitzlicht", + "strobe_level": "Blitzintensität", + "strobe_duty_cycle": "Blitzzyklus" }, "cube": { "shortCategoryName": "Würfel", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index ac15af5b8c..809eee0399 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -3310,6 +3310,27 @@ "low": "Low", "medium": "Medium", "high": "High" + }, + "mode": { + "stop": "Stop", + "burglar": "Burglar", + "fire": "Fire", + "emergency": "Emergency", + "police_panic": "Police panic", + "fire_panic": "Fire panic", + "emergency_panic": "Emergency panic" + }, + "level": { + "low": "Low", + "medium": "Medium", + "high": "High", + "very_high": "Very high" + }, + "strobe_level": { + "low": "Low", + "medium": "Medium", + "high": "High", + "very_high": "Very high" } } } @@ -3517,6 +3538,10 @@ } }, "deviceFeatureCategory": { + "ac-connected": { + "shortCategoryName": "AC Connected", + "binary": "AC Connected (yes/no)" + }, "light": { "shortCategoryName": "Light", "binary": "Light On/Off", @@ -3622,7 +3647,12 @@ "shortCategoryName": "Siren", "binary": "Siren", "lmh_volume": "Siren volume", - "melody": "Melody" + "melody": "Melody", + "mode": "Warning mode", + "level": "Sound level", + "strobe": "Strobe light", + "strobe_level": "Strobe intensity", + "strobe_duty_cycle": "Strobe duty cycle" }, "cube": { "shortCategoryName": "Cube", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 9423b8534a..eff2818de7 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -3310,6 +3310,27 @@ "low": "Faible", "medium": "Moyen", "high": "Fort" + }, + "mode": { + "stop": "Stop", + "burglar": "Intrusion", + "fire": "Incendie", + "emergency": "Urgence", + "police_panic": "Panique police", + "fire_panic": "Panique incendie", + "emergency_panic": "Panique urgence" + }, + "level": { + "low": "Faible", + "medium": "Moyen", + "high": "Fort", + "very_high": "Très fort" + }, + "strobe_level": { + "low": "Faible", + "medium": "Moyen", + "high": "Fort", + "very_high": "Très fort" } } } @@ -3517,6 +3538,10 @@ } }, "deviceFeatureCategory": { + "ac-connected": { + "shortCategoryName": "Secteur Connecté", + "binary": "Secteur Connecté (Oui/Non)" + }, "light": { "shortCategoryName": "Lumière", "binary": "Eclairage On/Off", @@ -3622,7 +3647,12 @@ "shortCategoryName": "Sirène", "binary": "Sirène On/Off", "lmh_volume": "Volume de la sirène", - "melody": "Mélodie" + "melody": "Mélodie", + "mode": "Mode d'alerte", + "level": "Niveau sonore", + "strobe": "Flash stroboscopique", + "strobe_level": "Intensité du flash", + "strobe_duty_cycle": "Cycle du flash" }, "cube": { "shortCategoryName": "Cube", diff --git a/front/src/utils/consts.js b/front/src/utils/consts.js index 14c465d53a..463d620e20 100644 --- a/front/src/utils/consts.js +++ b/front/src/utils/consts.js @@ -234,7 +234,12 @@ 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_CATEGORIES.TAMPER]: { [DEVICE_FEATURE_TYPES.SENSOR.BINARY]: 'shield' @@ -482,6 +487,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..f134bd65cb 100644 --- a/server/services/zigbee2mqtt/exposes/numericType.js +++ b/server/services/zigbee2mqtt/exposes/numericType.js @@ -23,6 +23,23 @@ module.exports = { type: DEVICE_FEATURE_TYPES.DURATION.DECIMAL, unit: DEVICE_FEATURE_UNITS.SECONDS, }, + 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: { feature: { 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..aaee76f41b 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,11 @@ 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', }, CHILD_LOCK: { BINARY: 'binary', @@ -1540,6 +1560,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;