From 3e11cf33a4942409994ef3301efcb33138dae371 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 4 Nov 2024 17:20:21 +0100 Subject: [PATCH 1/7] Scene: Device state trigger can now check that condition was valid for X minutes --- front/src/config/i18n/fr.json | 3 +- .../triggers/DeviceFeatureState.jsx | 62 +++ server/lib/scene/index.js | 1 + server/lib/scene/scene.checkTrigger.js | 2 +- server/lib/scene/scene.triggers.js | 116 ++++- server/models/scene.js | 1 + .../test/lib/scene/scene.checkTrigger.test.js | 190 -------- .../scene.trigger.deviceNewState.test.js | 432 ++++++++++++++++++ 8 files changed, 591 insertions(+), 216 deletions(-) create mode 100644 server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index a23d5b0bac..f92ed1335a 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -2096,7 +2096,8 @@ "on": "On", "off": "Off", "deviceSeen": "Si l'appareil est détecté", - "onlyExecuteAtThreshold": "Exécuter seulement lorsque le seuil est passé ( et non pas à chaque valeur envoyée )" + "onlyExecuteAtThreshold": "Exécuter seulement lorsque le seuil est passé ( et non pas à chaque valeur envoyée )", + "activateOrDeactivateForDuration": "Exécuter après que la condition ait été valide pendant : " }, "scheduledTrigger": { "typeLabel": "Type", diff --git a/front/src/routes/scene/edit-scene/triggers/DeviceFeatureState.jsx b/front/src/routes/scene/edit-scene/triggers/DeviceFeatureState.jsx index 2cac11f1c8..e45bc16f4f 100644 --- a/front/src/routes/scene/edit-scene/triggers/DeviceFeatureState.jsx +++ b/front/src/routes/scene/edit-scene/triggers/DeviceFeatureState.jsx @@ -1,5 +1,6 @@ import { Component } from 'preact'; import { connect } from 'unistore/preact'; +import { Text, Localizer } from 'preact-i18n'; import { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } from '../../../../../../server/utils/constants'; @@ -24,6 +25,24 @@ class TurnOnLight extends Component { } }; + onForDurationChange = e => { + e.preventDefault(); + if (e.target.value) { + this.props.updateTriggerProperty(this.props.index, 'for_duration', Number(e.target.value) * 60 * 1000); + } else { + this.props.updateTriggerProperty(this.props.index, 'for_duration', ''); + } + }; + + enableOrDisableForDuration = e => { + e.preventDefault(); + if (e.target.checked) { + this.props.updateTriggerProperty(this.props.index, 'for_duration', 60 * 1000); + } else { + this.props.updateTriggerProperty(this.props.index, 'for_duration', undefined); + } + }; + render(props, { selectedDeviceFeature }) { let binaryDevice = false; let presenceDevice = false; @@ -61,6 +80,49 @@ class TurnOnLight extends Component { {defaultDevice && } {thresholdDevice && } +
+
+ +
+
+ {props.trigger.for_duration !== undefined && ( +
+
+
+
+ + } + value={ + Number.isInteger(props.trigger.for_duration) + ? props.trigger.for_duration / 60 / 1000 + : props.trigger.for_duration + } + onChange={this.onForDurationChange} + /> + + + + + + +
+
+
+
+ )} ); } diff --git a/server/lib/scene/index.js b/server/lib/scene/index.js index 1e1f26298d..eb141e567c 100644 --- a/server/lib/scene/index.js +++ b/server/lib/scene/index.js @@ -57,6 +57,7 @@ const SceneManager = function SceneManager( this.sunCalc = sunCalc; this.scheduler = scheduler; this.jobs = []; + this.checkTriggersDurationTimer = new Map(); this.event.on(EVENTS.TRIGGERS.CHECK, eventFunctionWrapper(this.checkTrigger.bind(this))); this.event.on(EVENTS.ACTION.TRIGGERED, eventFunctionWrapper(this.executeSingleAction.bind(this))); // on timezone change, reload all scenes diff --git a/server/lib/scene/scene.checkTrigger.js b/server/lib/scene/scene.checkTrigger.js index 8143e760e8..3d93c25b49 100644 --- a/server/lib/scene/scene.checkTrigger.js +++ b/server/lib/scene/scene.checkTrigger.js @@ -30,7 +30,7 @@ function checkTrigger(event) { if (event.type === trigger.type) { logger.debug(`Trigger ${trigger.type} is matching with event`); // then we check the condition is verified - const conditionVerified = triggersFunc[event.type](event, trigger); + const conditionVerified = triggersFunc[event.type](this, sceneSelector, event, trigger); logger.debug(`Trigger ${trigger.type}, conditionVerified = ${conditionVerified}...`); // if yes, we execute the scene diff --git a/server/lib/scene/scene.triggers.js b/server/lib/scene/scene.triggers.js index 6761088484..a557459d61 100644 --- a/server/lib/scene/scene.triggers.js +++ b/server/lib/scene/scene.triggers.js @@ -1,38 +1,106 @@ +const cloneDeep = require('lodash.clonedeep'); + +const logger = require('../../utils/logger'); const { EVENTS } = require('../../utils/constants'); const { compare } = require('../../utils/compare'); const triggersFunc = { - [EVENTS.DEVICE.NEW_STATE]: (event, trigger) => { - // we check that we are talking about the same event + [EVENTS.DEVICE.NEW_STATE]: (self, sceneSelector, event, trigger) => { + // we check that we are talking about the same device feature if (event.device_feature !== trigger.device_feature) { return false; } + + // We verify if both old value and new value validate the rule const newValueValidateRule = compare(trigger.operator, event.last_value, trigger.value); - // if the trigger is only a threshold_only, we only validate the trigger is the rule has been validated - // and was not validated with the previous value - if (trigger.threshold_only === true && !Number.isNaN(event.previous_value)) { - const previousValueValidateRule = compare(trigger.operator, event.previous_value, trigger.value); - return newValueValidateRule && !previousValueValidateRule; + const previousValueValidateRule = compare(trigger.operator, event.previous_value, trigger.value); + + const triggerDurationKey = `device.new-state.${sceneSelector}.${trigger.device_feature}:${trigger.operator}:${trigger.value}`; + + // If the previous value was validating the rule, and the new value is not + // We clear any timeout for this trigger + if (previousValueValidateRule && !newValueValidateRule && self.checkTriggersDurationTimer.get(triggerDurationKey)) { + logger.info( + `Cancelling timer on trigger for device_feature ${trigger.device_feature}, because condition is no longer valid`, + ); + clearTimeout(self.checkTriggersDurationTimer.get(triggerDurationKey)); + self.checkTriggersDurationTimer.delete(triggerDurationKey); + } + + if (trigger.for_duration === undefined) { + // If the trigger is only a threshold_only, we only validate the trigger is the rule has been validated + // and was not validated with the previous value + if (trigger.threshold_only === true && !Number.isNaN(event.previous_value)) { + return newValueValidateRule && !previousValueValidateRule; + } + + return newValueValidateRule; } - return newValueValidateRule; + + // If the "for_duration_finished" is here, it means we are + // checking the state after the timeout + if (event.for_duration_finished && triggerDurationKey === event.trigger_duration_key) { + logger.info(`Scene trigger device.new-state: Timer for sensor ${trigger.device_feature} has finished.`); + clearTimeout(self.checkTriggersDurationTimer.get(triggerDurationKey)); + self.checkTriggersDurationTimer.delete(triggerDurationKey); + return newValueValidateRule; + } + + const isValidatedIfThresholdOnly = + trigger.threshold_only && !Number.isNaN(event.previous_value) + ? newValueValidateRule && !previousValueValidateRule + : true; + + if (newValueValidateRule && isValidatedIfThresholdOnly) { + // If the timeout already exist, don't re-create it + const timeoutAlreadyExist = self.checkTriggersDurationTimer.get(triggerDurationKey); + if (timeoutAlreadyExist) { + logger.info(`Timer for "${trigger.device_feature}" already exist, not re-creating.`); + return false; + } + logger.info( + `Scheduling timer to check for device_feature "${trigger.device_feature}" state in ${trigger.for_duration}ms`, + ); + // Create a timeout + const timeoutId = setTimeout(() => { + const lastValue = self.stateManager.get('deviceFeature', trigger.device_feature).last_value; + self.event.emit(EVENTS.TRIGGERS.CHECK, { + ...cloneDeep(event), + previous_value: event.last_value, + last_value: lastValue, + for_duration_finished: true, + trigger_duration_key: triggerDurationKey, + }); + }, trigger.for_duration); + // Save the timeoutId in case we need to cancel it later + self.checkTriggersDurationTimer.set(triggerDurationKey, timeoutId); + // Return false, as we'll check this only in the future + return false; + } + + return false; }, - [EVENTS.TIME.CHANGED]: (event, trigger) => event.key === trigger.key, - [EVENTS.TIME.SUNRISE]: (event, trigger) => event.house.selector === trigger.house, - [EVENTS.TIME.SUNSET]: (event, trigger) => event.house.selector === trigger.house, - [EVENTS.USER_PRESENCE.BACK_HOME]: (event, trigger) => event.house === trigger.house && event.user === trigger.user, - [EVENTS.USER_PRESENCE.LEFT_HOME]: (event, trigger) => event.house === trigger.house && event.user === trigger.user, - [EVENTS.HOUSE.EMPTY]: (event, trigger) => event.house === trigger.house, - [EVENTS.HOUSE.NO_LONGER_EMPTY]: (event, trigger) => event.house === trigger.house, - [EVENTS.AREA.USER_ENTERED]: (event, trigger) => event.user === trigger.user && event.area === trigger.area, - [EVENTS.AREA.USER_LEFT]: (event, trigger) => event.user === trigger.user && event.area === trigger.area, - [EVENTS.ALARM.ARM]: (event, trigger) => event.house === trigger.house, - [EVENTS.ALARM.ARMING]: (event, trigger) => event.house === trigger.house, - [EVENTS.ALARM.DISARM]: (event, trigger) => event.house === trigger.house, - [EVENTS.ALARM.PARTIAL_ARM]: (event, trigger) => event.house === trigger.house, - [EVENTS.ALARM.PANIC]: (event, trigger) => event.house === trigger.house, - [EVENTS.ALARM.TOO_MANY_CODES_TESTS]: (event, trigger) => event.house === trigger.house, + [EVENTS.TIME.CHANGED]: (self, sceneSelector, event, trigger) => event.key === trigger.key, + [EVENTS.TIME.SUNRISE]: (self, sceneSelector, event, trigger) => event.house.selector === trigger.house, + [EVENTS.TIME.SUNSET]: (self, sceneSelector, event, trigger) => event.house.selector === trigger.house, + [EVENTS.USER_PRESENCE.BACK_HOME]: (self, sceneSelector, event, trigger) => + event.house === trigger.house && event.user === trigger.user, + [EVENTS.USER_PRESENCE.LEFT_HOME]: (self, sceneSelector, event, trigger) => + event.house === trigger.house && event.user === trigger.user, + [EVENTS.HOUSE.EMPTY]: (self, sceneSelector, event, trigger) => event.house === trigger.house, + [EVENTS.HOUSE.NO_LONGER_EMPTY]: (self, sceneSelector, event, trigger) => event.house === trigger.house, + [EVENTS.AREA.USER_ENTERED]: (self, sceneSelector, event, trigger) => + event.user === trigger.user && event.area === trigger.area, + [EVENTS.AREA.USER_LEFT]: (self, sceneSelector, event, trigger) => + event.user === trigger.user && event.area === trigger.area, + [EVENTS.ALARM.ARM]: (self, sceneSelector, event, trigger) => event.house === trigger.house, + [EVENTS.ALARM.ARMING]: (self, sceneSelector, event, trigger) => event.house === trigger.house, + [EVENTS.ALARM.DISARM]: (self, sceneSelector, event, trigger) => event.house === trigger.house, + [EVENTS.ALARM.PARTIAL_ARM]: (self, sceneSelector, event, trigger) => event.house === trigger.house, + [EVENTS.ALARM.PANIC]: (self, sceneSelector, event, trigger) => event.house === trigger.house, + [EVENTS.ALARM.TOO_MANY_CODES_TESTS]: (self, sceneSelector, event, trigger) => event.house === trigger.house, [EVENTS.SYSTEM.START]: () => true, - [EVENTS.MQTT.RECEIVED]: (event, trigger) => + [EVENTS.MQTT.RECEIVED]: (self, sceneSelector, event, trigger) => event.topic === trigger.topic && (trigger.message === '' || trigger.message === event.message), }; diff --git a/server/models/scene.js b/server/models/scene.js index 8143ab3097..7c91a90c7b 100644 --- a/server/models/scene.js +++ b/server/models/scene.js @@ -99,6 +99,7 @@ const triggersSchema = Joi.array().items( time: Joi.string().regex(/^([0-9]{2}):([0-9]{2})$/), interval: Joi.number(), unit: Joi.string(), + for_duration: Joi.number(), days_of_the_week: Joi.array().items( Joi.string().valid('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'), ), diff --git a/server/test/lib/scene/scene.checkTrigger.test.js b/server/test/lib/scene/scene.checkTrigger.test.js index 4ab3be0d81..7b43695395 100644 --- a/server/test/lib/scene/scene.checkTrigger.test.js +++ b/server/test/lib/scene/scene.checkTrigger.test.js @@ -57,80 +57,6 @@ describe('scene.checkTrigger', () => { sinon.reset(); }); - it('should execute scene', async () => { - sceneManager.addScene({ - selector: 'my-scene', - active: true, - actions: [ - [ - { - type: ACTIONS.LIGHT.TURN_ON, - devices: ['light-1'], - }, - ], - ], - triggers: [ - { - type: EVENTS.DEVICE.NEW_STATE, - device_feature: 'light-1', - value: 12, - operator: '=', - }, - ], - }); - sceneManager.checkTrigger({ - type: EVENTS.DEVICE.NEW_STATE, - device_feature: 'light-1', - last_value: 12, - }); - return new Promise((resolve, reject) => { - sceneManager.queue.start(() => { - try { - assert.calledOnce(device.setValue); - resolve(); - } catch (e) { - reject(e); - } - }); - }); - }); - it('should not execute scene, scene not active', async () => { - sceneManager.addScene({ - selector: 'my-scene', - active: false, - actions: [ - [ - { - type: ACTIONS.LIGHT.TURN_ON, - devices: ['light-1'], - }, - ], - ], - triggers: [ - { - type: EVENTS.DEVICE.NEW_STATE, - device_feature: 'light-1', - value: 12, - operator: '=', - }, - ], - }); - sceneManager.checkTrigger({ - type: EVENTS.DEVICE.NEW_STATE, - device_feature: 'light-1', - last_value: 12, - }); - return new Promise((resolve, reject) => { - sceneManager.queue.start(() => { - try { - assert.notCalled(device.setValue); - resolve(); - } catch (e) { - reject(e); - } - }); - }); - }); it('should execute scene', async () => { const addedScene = sceneManager.addScene({ selector: 'my-scene', @@ -489,122 +415,6 @@ describe('scene.checkTrigger', () => { }); }); }); - - it('should not execute scene, condition not verified', async () => { - sceneManager.addScene({ - selector: 'my-scene', - active: true, - actions: [ - [ - { - type: ACTIONS.LIGHT.TURN_ON, - devices: ['light-1'], - }, - ], - ], - triggers: [ - { - type: EVENTS.DEVICE.NEW_STATE, - device_feature: 'light-1', - value: 12, - operator: '=', - }, - ], - }); - sceneManager.checkTrigger({ - type: EVENTS.DEVICE.NEW_STATE, - device_feature: 'light-1', - last_value: 14, - }); - return new Promise((resolve, reject) => { - sceneManager.queue.start(() => { - try { - assert.notCalled(device.setValue); - resolve(); - } catch (e) { - reject(e); - } - }); - }); - }); - it('should not execute scene, threshold already passed', async () => { - sceneManager.addScene({ - selector: 'my-scene', - active: true, - actions: [ - [ - { - type: ACTIONS.LIGHT.TURN_ON, - devices: ['light-1'], - }, - ], - ], - triggers: [ - { - type: EVENTS.DEVICE.NEW_STATE, - device_feature: 'light-1', - value: 12, - operator: '>', - threshold_only: true, - }, - ], - }); - sceneManager.checkTrigger({ - type: EVENTS.DEVICE.NEW_STATE, - device_feature: 'light-1', - previous_value: 14, - last_value: 14, - }); - return new Promise((resolve, reject) => { - sceneManager.queue.start(() => { - try { - assert.notCalled(device.setValue); - resolve(); - } catch (e) { - reject(e); - } - }); - }); - }); - it('should execute scene, threshold passed for the first time', async () => { - sceneManager.addScene({ - selector: 'my-scene', - active: true, - actions: [ - [ - { - type: ACTIONS.LIGHT.TURN_ON, - devices: ['light-1'], - }, - ], - ], - triggers: [ - { - type: EVENTS.DEVICE.NEW_STATE, - device_feature: 'light-1', - value: 12, - operator: '>', - threshold_only: true, - }, - ], - }); - sceneManager.checkTrigger({ - type: EVENTS.DEVICE.NEW_STATE, - device_feature: 'light-1', - previous_value: 11, - last_value: 14, - }); - return new Promise((resolve, reject) => { - sceneManager.queue.start(() => { - try { - assert.calledOnce(device.setValue); - resolve(); - } catch (e) { - reject(e); - } - }); - }); - }); it('should not execute scene, event not matching', async () => { sceneManager.addScene({ selector: 'my-scene', diff --git a/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js b/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js new file mode 100644 index 0000000000..9a8684ba9d --- /dev/null +++ b/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js @@ -0,0 +1,432 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); +const EventEmitter = require('events'); +const Promise = require('bluebird'); + +const { assert, fake } = sinon; + +const { EVENTS, ACTIONS } = require('../../../../utils/constants'); +const SceneManager = require('../../../../lib/scene'); +const StateManager = require('../../../../lib/state'); + +const event = new EventEmitter(); + +describe('scene.triggers.deviceNewState', () => { + let sceneManager; + + const device = { + setValue: fake.resolves(null), + }; + + const brain = {}; + + const service = { + getService: fake.returns({ + device: { + subscribe: fake.returns(null), + }, + }), + }; + + beforeEach(() => { + const house = { + get: fake.resolves([]), + }; + + const scheduler = { + scheduleJob: (date, callback) => { + return { + callback, + date, + cancel: () => {}, + }; + }, + }; + + brain.addNamedEntity = fake.returns(null); + brain.removeNamedEntity = fake.returns(null); + + const stateManager = new StateManager(); + stateManager.setState('deviceFeature', 'light-1', { + last_value: 14, + }); + sceneManager = new SceneManager(stateManager, event, device, {}, {}, house, {}, {}, {}, scheduler, brain, service); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should execute scene', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + value: 12, + operator: '=', + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + last_value: 12, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.calledOnce(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + it('should not execute scene, scene not active', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: false, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + value: 12, + operator: '=', + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + last_value: 12, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.notCalled(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + it('should not execute scene, condition not verified', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + value: 12, + operator: '=', + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + last_value: 14, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.notCalled(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + it('should not execute scene, threshold already passed', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + value: 12, + operator: '>', + threshold_only: true, + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + previous_value: 14, + last_value: 14, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.notCalled(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + it('should execute scene, threshold passed for the first time', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + value: 12, + operator: '>', + threshold_only: true, + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + previous_value: 11, + last_value: 14, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.calledOnce(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + it('should start timer to check later for state and not follow current scene', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + value: 12, + operator: '>', + threshold_only: true, + for_duration: 10 * 60 * 1000, + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + previous_value: 11, + last_value: 14, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.notCalled(device.setValue); + expect(sceneManager.checkTriggersDurationTimer.size).to.equal(1); + sceneManager.checkTriggersDurationTimer.forEach((value, timeoutKey) => { + expect(timeoutKey).to.equal('device.new-state.my-scene.light-1:>:12'); + clearTimeout(value); + }); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + it('should start timer to check now and condition should still be valid on second call', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + value: 12, + operator: '>', + threshold_only: true, + for_duration: 0, // now + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + previous_value: 11, + last_value: 14, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(async () => { + try { + await Promise.delay(5); + assert.calledOnce(device.setValue); + expect(sceneManager.checkTriggersDurationTimer.size).to.equal(0); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + it('should start timer to check now and condition should still be valid on second call', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + value: 12, + operator: '>', + threshold_only: false, + for_duration: 5, + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + previous_value: 11, + last_value: 14, + }); + sceneManager.checkTrigger({ + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + previous_value: 14, + last_value: 14, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(async () => { + try { + await Promise.delay(10); + assert.calledOnce(device.setValue); + expect(sceneManager.checkTriggersDurationTimer.size).to.equal(0); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + it('should start timer to check now and condition should not be valid on second call', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + value: 12, + operator: '>', + threshold_only: true, + for_duration: 10, // In 10ms + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + previous_value: 11, + last_value: 14, + }); + sceneManager.checkTrigger({ + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + previous_value: 14, + last_value: 5, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(async () => { + try { + await Promise.delay(5); + assert.notCalled(device.setValue); + expect(sceneManager.checkTriggersDurationTimer.size).to.equal(0); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); +}); From 2ec9244cb6acc99f69c6d76d39e95cfd9bafefa8 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 4 Nov 2024 17:20:30 +0100 Subject: [PATCH 2/7] improve i18n --- front/src/config/i18n/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index f92ed1335a..b62465e797 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -2097,7 +2097,7 @@ "off": "Off", "deviceSeen": "Si l'appareil est détecté", "onlyExecuteAtThreshold": "Exécuter seulement lorsque le seuil est passé ( et non pas à chaque valeur envoyée )", - "activateOrDeactivateForDuration": "Exécuter après que la condition ait été valide pendant : " + "activateOrDeactivateForDuration": "Exécuter la scène après que la condition ait été valide pendant : " }, "scheduledTrigger": { "typeLabel": "Type", From c2e3129f6b7ef2a33536cced455d8ad7d80d1c57 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 4 Nov 2024 17:28:04 +0100 Subject: [PATCH 3/7] Better scope tests --- .../triggers/scene.trigger.deviceNewState.test.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js b/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js index 9a8684ba9d..34d5c538f9 100644 --- a/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js +++ b/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js @@ -11,12 +11,9 @@ const StateManager = require('../../../../lib/state'); const event = new EventEmitter(); -describe('scene.triggers.deviceNewState', () => { +describe.only('scene.triggers.deviceNewState', () => { let sceneManager; - - const device = { - setValue: fake.resolves(null), - }; + let device; const brain = {}; @@ -33,6 +30,10 @@ describe('scene.triggers.deviceNewState', () => { get: fake.resolves([]), }; + device = { + setValue: fake.resolves(null), + }; + const scheduler = { scheduleJob: (date, callback) => { return { @@ -333,7 +334,7 @@ describe('scene.triggers.deviceNewState', () => { }); }); }); - it('should start timer to check now and condition should still be valid on second call', async () => { + it('should start timer to check now and re-send new value still validating the condition', async () => { sceneManager.addScene({ selector: 'my-scene', active: true, From 381eafdbe1088d13647ce4bb7810bdcc1f83a2da Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 4 Nov 2024 17:32:46 +0100 Subject: [PATCH 4/7] Remove .only --- .../lib/scene/triggers/scene.trigger.deviceNewState.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js b/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js index 34d5c538f9..26315f2115 100644 --- a/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js +++ b/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js @@ -11,7 +11,7 @@ const StateManager = require('../../../../lib/state'); const event = new EventEmitter(); -describe.only('scene.triggers.deviceNewState', () => { +describe('scene.triggers.deviceNewState', () => { let sceneManager; let device; From 79e5c6cacf11582340ab3ac125afdd5cb55f9ff9 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 4 Nov 2024 17:40:09 +0100 Subject: [PATCH 5/7] Add missing test --- .../scene.trigger.deviceNewState.test.js | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js b/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js index 26315f2115..374661573f 100644 --- a/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js +++ b/server/test/lib/scene/triggers/scene.trigger.deviceNewState.test.js @@ -169,6 +169,43 @@ describe('scene.triggers.deviceNewState', () => { }); }); }); + it('should not execute scene, device feature is not the same', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_ON, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-1', + value: 12, + operator: '=', + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.DEVICE.NEW_STATE, + device_feature: 'light-2', + last_value: 14, + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.notCalled(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); it('should not execute scene, threshold already passed', async () => { sceneManager.addScene({ selector: 'my-scene', From 10684196d67ae5fc1acd7f04ae9772ae709004ed Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 4 Nov 2024 17:41:26 +0100 Subject: [PATCH 6/7] Add EN & DE translations --- front/src/config/i18n/de.json | 3 ++- front/src/config/i18n/en.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index 2ef3b832c0..ccdbf32e4e 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -2096,7 +2096,8 @@ "on": "Ein", "off": "Aus", "deviceSeen": "Wenn das Gerät erkannt wird", - "onlyExecuteAtThreshold": "Nur ausführen, wenn der Schwellenwert überschritten wird (und nicht bei jedem vom Gerät gesendeten Wert)" + "onlyExecuteAtThreshold": "Nur ausführen, wenn der Schwellenwert überschritten wird (und nicht bei jedem vom Gerät gesendeten Wert)", + "activateOrDeactivateForDuration": "Szene ausführen, nachdem die Bedingung für die Dauer gültig war:" }, "scheduledTrigger": { "typeLabel": "Typ", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 05c245aa73..e5d86ee83d 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -2096,7 +2096,8 @@ "on": "On", "off": "Off", "deviceSeen": "If the device is detected", - "onlyExecuteAtThreshold": "Execute only when threshold is passed (and not at every value sent by the device)" + "onlyExecuteAtThreshold": "Execute only when threshold is passed (and not at every value sent by the device)", + "activateOrDeactivateForDuration": "Run the scene after the condition has been valid for:" }, "scheduledTrigger": { "typeLabel": "Type", From 51b221d063c6add8ddfc42ede2d01bd05885ab97 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 8 Nov 2024 17:04:32 +0100 Subject: [PATCH 7/7] Add missing test --- .../triggers/scene.trigger.alarmMode.test.js | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js b/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js index 283bdf61f7..e8db1371e4 100644 --- a/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js +++ b/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js @@ -215,6 +215,40 @@ describe('Scene.triggers.alarmMode', () => { }); }); }); + it('should execute scene with alarm.too-many-codes-tests trigger', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_OFF, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.ALARM.TOO_MANY_CODES_TESTS, + house: 'house-1', + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.ALARM.TOO_MANY_CODES_TESTS, + house: 'house-1', + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.calledOnce(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); it('should not execute scene (house not matching)', async () => { sceneManager.addScene({ selector: 'my-scene',