Skip to content

Commit

Permalink
Scene: Device state trigger can now check that condition was valid fo…
Browse files Browse the repository at this point in the history
…r X minutes (#2156)
  • Loading branch information
Pierre-Gilles authored Nov 8, 2024
1 parent 70c64b2 commit 2540927
Show file tree
Hide file tree
Showing 11 changed files with 667 additions and 218 deletions.
3 changes: 2 additions & 1 deletion front/src/config/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion front/src/config/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion front/src/config/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 la scène après que la condition ait été valide pendant : "
},
"scheduledTrigger": {
"typeLabel": "Type",
Expand Down
62 changes: 62 additions & 0 deletions front/src/routes/scene/edit-scene/triggers/DeviceFeatureState.jsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
Expand Down Expand Up @@ -61,6 +80,49 @@ class TurnOnLight extends Component {
{defaultDevice && <DefaultDeviceState {...props} selectedDeviceFeature={selectedDeviceFeature} />}
</div>
{thresholdDevice && <ThresholdDeviceState {...props} />}
<div class="row">
<div class="col-12">
<label class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
checked={props.trigger.for_duration !== undefined}
onChange={this.enableOrDisableForDuration}
/>
<span class="form-check-label">
<Text id="editScene.triggersCard.newState.activateOrDeactivateForDuration" />
</span>
</label>
</div>
</div>
{props.trigger.for_duration !== undefined && (
<div class="row">
<div class="col">
<div class="form-group">
<div class="input-group">
<Localizer>
<input
type="number"
class="form-control"
placeholder={<Text id="editScene.triggersCard.newState.valuePlaceholder" />}
value={
Number.isInteger(props.trigger.for_duration)
? props.trigger.for_duration / 60 / 1000
: props.trigger.for_duration
}
onChange={this.onForDurationChange}
/>
</Localizer>
<span class="input-group-append" id="basic-addon2">
<span class="input-group-text">
<Text id="deviceFeatureUnitShort.minutes" />
</span>
</span>
</div>
</div>
</div>
</div>
)}
</div>
);
}
Expand Down
1 change: 1 addition & 0 deletions server/lib/scene/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion server/lib/scene/scene.checkTrigger.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 92 additions & 24 deletions server/lib/scene/scene.triggers.js
Original file line number Diff line number Diff line change
@@ -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),
};

Expand Down
1 change: 1 addition & 0 deletions server/models/scene.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
),
Expand Down
Loading

0 comments on commit 2540927

Please sign in to comment.