Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion front/src/config/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -3282,6 +3282,12 @@
"label": "Wähle eine Szene aus",
"notice": "Die Auslösebedingungen der aufgerufenen Szene werden ignoriert. Gladys gerät nicht in eine Endlosschleife, wenn eine Szene eine andere Szene aufruft oder umgekehrt: Jede Szene wird einmal ausgeführt."
},
"setSceneActive": {
"label": "Wähle eine Szene aus",
"disabledScene": "deaktiviert",
"enableNotice": "Die ausgewählte Szene wird aktiviert: Ihre Auslöser werden wieder überwacht. Eine Szene kann sich selbst aktivieren.",
"disableNotice": "Die ausgewählte Szene wird deaktiviert: Ihre Auslöser werden nicht mehr überwacht, bis sie wieder aktiviert wird. Eine Szene kann sich selbst deaktivieren, zum Beispiel um nur einmal ausgeführt zu werden, bis eine andere Szene sie wieder aktiviert."
},
"houseEmptyOrNot": {
"houseIsEmptyDescription": "Wenn das Zuhause leer ist, wird die Szene fortgesetzt. Andernfalls wird die Szene gestoppt.",
"houseIsNotEmptyDescription": "Wenn das Zuhause nicht leer ist, wird die Szene fortgesetzt. Andernfalls wird die Szene gestoppt.",
Expand Down Expand Up @@ -3395,7 +3401,9 @@
"toggle": "Schalter umschalten"
},
"scene": {
"start": "Szene starten"
"start": "Szene starten",
"enable": "Eine Szene aktivieren",
"disable": "Eine Szene deaktivieren"
},
"service": {
"start": "Dienst starten",
Expand Down
10 changes: 9 additions & 1 deletion front/src/config/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3282,6 +3282,12 @@
"label": "Select a scene",
"notice": "The trigger conditions of the called scene will be ignored. If a scene calls another scene, and vice versa, Gladys will not fall into an infinite loop: each scene will be executed once."
},
"setSceneActive": {
"label": "Select a scene",
"disabledScene": "disabled",
"enableNotice": "The selected scene will be enabled: its triggers will start listening again. A scene can enable itself.",
"disableNotice": "The selected scene will be disabled: its triggers will stop listening until it is enabled again. A scene can disable itself, for example to run only once until another scene re-enables it."
},
"houseEmptyOrNot": {
"houseIsEmptyDescription": "If the house is empty, the scene will continue. Otherwise, the scene will stop.",
"houseIsNotEmptyDescription": "If the house is not empty, the scene will continue. Otherwise, the scene will stop.",
Expand Down Expand Up @@ -3395,7 +3401,9 @@
"toggle": "Toggle the Switches"
},
"scene": {
"start": "Start scene"
"start": "Start scene",
"enable": "Enable a scene",
"disable": "Disable a scene"
},
"service": {
"start": "Start service",
Expand Down
10 changes: 9 additions & 1 deletion front/src/config/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -3282,6 +3282,12 @@
"label": "Choisir une scène",
"notice": "Les conditions de déclenchements de la scène appelée seront ignorées. Si une scène appelle une autre scène, et vice-versa, Gladys ne tombera pas dans une boucle infinie: chaque scène ne sera exécutée qu'une seule fois"
},
"setSceneActive": {
"label": "Choisir une scène",
"disabledScene": "désactivée",
"enableNotice": "La scène sélectionnée sera activée : ses déclencheurs seront de nouveau écoutés. Une scène peut s'activer elle-même.",
"disableNotice": "La scène sélectionnée sera désactivée : ses déclencheurs ne seront plus écoutés jusqu'à sa réactivation. Une scène peut se désactiver elle-même, par exemple pour ne s'exécuter qu'une fois jusqu'à ce qu'une autre scène la réactive."
},
"houseEmptyOrNot": {
"houseIsEmptyDescription": "Si la maison est vide, cette scène continuera. Sinon, la scène s'arrêtera.",
"houseIsNotEmptyDescription": "Si la maison n'est pas vide, cette scène continuera. Sinon, la scène s'arrêtera.",
Expand Down Expand Up @@ -3395,7 +3401,9 @@
"toggle": "Inverser les prises"
},
"scene": {
"start": "Démarrer la scène"
"start": "Démarrer la scène",
"enable": "Activer une scène",
"disable": "Désactiver une scène"
},
"service": {
"start": "Démarrer le service",
Expand Down
5 changes: 5 additions & 0 deletions front/src/routes/scene/edit-scene/ActionCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import TurnOnOffLightParams from './actions/TurnOnOffLightParams';
import BlinkLightParams from './actions/BlinkLightParams';
import TurnOnOffSwitchParams from './actions/TurnOnOffSwitchParams';
import StartSceneParams from './actions/StartSceneParams';
import EnableDisableSceneParams from './actions/EnableDisableSceneParams';
import UserPresence from './actions/UserPresence';
import HttpRequest from './actions/HttpRequest';
import CheckUserPresence from './actions/CheckUserPresence';
Expand Down Expand Up @@ -60,6 +61,8 @@ const ACTION_ICON = {
[ACTIONS.USER.CHECK_PRESENCE]: 'fe fe-home',
[ACTIONS.CONDITION.CHECK_TIME]: 'fe fe-watch',
[ACTIONS.SCENE.START]: 'fe fe-fast-forward',
[ACTIONS.SCENE.ENABLE]: 'fe fe-play-circle',
[ACTIONS.SCENE.DISABLE]: 'fe fe-pause-circle',
[ACTIONS.HOUSE.IS_EMPTY]: 'fe fe-home',
[ACTIONS.HOUSE.IS_NOT_EMPTY]: 'fe fe-home',
[ACTIONS.DEVICE.SET_VALUE]: 'fe fe-radio',
Expand Down Expand Up @@ -97,6 +100,8 @@ const ACTION_COMPONENTS = {
[ACTIONS.HTTP.REQUEST]: HttpRequest,
[ACTIONS.CONDITION.CHECK_TIME]: CheckTime,
[ACTIONS.SCENE.START]: StartSceneParams,
[ACTIONS.SCENE.ENABLE]: EnableDisableSceneParams,
[ACTIONS.SCENE.DISABLE]: EnableDisableSceneParams,
[ACTIONS.HOUSE.IS_EMPTY]: HouseEmptyOrNotCondition,
[ACTIONS.HOUSE.IS_NOT_EMPTY]: HouseEmptyOrNotCondition,
[ACTIONS.DEVICE.SET_VALUE]: DeviceSetValue,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const ACTION_LIST = [
ACTIONS.HTTP.REQUEST,
ACTIONS.CONDITION.CHECK_TIME,
ACTIONS.SCENE.START,
ACTIONS.SCENE.ENABLE,
ACTIONS.SCENE.DISABLE,
ACTIONS.HOUSE.IS_EMPTY,
ACTIONS.HOUSE.IS_NOT_EMPTY,
ACTIONS.DEVICE.SET_VALUE,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Component } from 'preact';
import { connect } from 'unistore/preact';
import { Text, withText } from 'preact-i18n';
import Select from 'react-select';

import { ACTIONS } from '../../../../../../server/utils/constants';
import actions from '../../../../actions/scene';

class EnableDisableSceneParams extends Component {
handleChange = selectedOption => {
if (selectedOption) {
this.props.updateActionProperty(this.props.path, 'scene', selectedOption.value);
} else {
this.props.updateActionProperty(this.props.path, 'scene', null);
}
};

refreshSelectedOptions = nextProps => {
let selectedOption = null;
let scenes = this.state.scenes || [];

// Contrary to the "start a scene" action, the current scene is part of the list:
// a scene disabling itself is the way to build a "run once, then disarm" scene.
if (scenes.length === 0 && nextProps.scenes) {
// Already disabled scenes are the most common target of an "enable a scene" action,
// so the current state of each scene is displayed next to its name.
scenes = nextProps.scenes.map(scene => ({
value: scene.selector,
label: scene.active ? scene.name : `${scene.name} (${nextProps.disabledSceneLabel})`
}));
}

if (nextProps.action.scene && scenes.length > 0) {
selectedOption = scenes.find(scene => scene.value === nextProps.action.scene) || null;
}

this.setState({ selectedOption, scenes });
};

constructor(props) {
super(props);
this.state = {
selectedOption: null
};
}

async componentDidMount() {
await this.props.getScenes();
}

componentWillReceiveProps(nextProps) {
this.refreshSelectedOptions(nextProps);
}

render(props, { selectedOption, scenes }) {
const enabling = props.action.type === ACTIONS.SCENE.ENABLE;
return (
<div class="form-group">
<div class="alert alert-info">
{enabling && <Text id="editScene.actionsCard.setSceneActive.enableNotice" />}
{!enabling && <Text id="editScene.actionsCard.setSceneActive.disableNotice" />}
</div>
<label class="form-label">
<Text id="editScene.actionsCard.setSceneActive.label" />
</label>
<Select
value={selectedOption}
onChange={this.handleChange}
options={scenes}
className="react-select-container"
classNamePrefix="react-select"
/>
</div>
);
}
}

export default withText({
disabledSceneLabel: 'editScene.actionsCard.setSceneActive.disabledScene'
})(connect('scenes', actions)(EnableDisableSceneParams));
42 changes: 41 additions & 1 deletion server/lib/scene/scene.actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const timezone = require('dayjs/plugin/timezone');

const { ACTIONS, DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES, ALARM_MODES } = require('../../utils/constants');
const { getDeviceFeature } = require('../../utils/device');
const { AbortScene } = require('../../utils/coreErrors');
const { AbortScene, NotFoundError } = require('../../utils/coreErrors');
const { compare } = require('../../utils/compare');
const { parseJsonIfJson } = require('../../utils/json');
const logger = require('../../utils/logger');
Expand Down Expand Up @@ -78,6 +78,40 @@ function warnIfInvalidJsonMessage(actionName, topic, message) {
}
}

/**
* @description Enable or disable another scene by turning its "active" flag on or off.
* A scene is allowed to target itself: it's how a scene can disarm itself after
* having done its job, and be re-armed later by another scene.
* @param {object} self - The scene manager.
* @param {object} action - The scene action.
* @param {boolean} active - New value of the "active" flag of the target scene.
* @returns {Promise} Resolve when the target scene was updated.
* @example await setSceneActive(self, { scene: 'my-scene' }, true);
*/
async function setSceneActive(self, action, active) {
if (!action.scene) {
throw new AbortScene('SCENE_NOT_FOUND');
}
// `scene.update` re-adds the scene to the live store, which cancels its triggers and
// schedules them again. That's wanted when the flag really changes, but an action which
// keeps a scene in the state it's already in (a scene re-arming itself on every run for
// example) would otherwise restart interval jobs and drop pending "for duration" timers
// on each execution, so an interval trigger could never reach its next tick.
const currentScene = self.scenes && self.scenes[action.scene];
if (currentScene && currentScene.active === active) {
return;
}
try {
await self.update(action.scene, { active });
Comment thread
cursor[bot] marked this conversation as resolved.
} catch (e) {
if (e instanceof NotFoundError) {
logger.warn(`Scene "${action.scene}" was not found, it cannot be ${active ? 'enabled' : 'disabled'}.`);
throw new AbortScene('SCENE_NOT_FOUND');
}
throw e;
}
}

const actionsFunc = {
[ACTIONS.DEVICE.SET_VALUE]: async (self, action, scope) => {
let device;
Expand Down Expand Up @@ -327,6 +361,12 @@ const actionsFunc = {
// other scenes writing on the same scope: it needs to be a fresh object
self.execute(action.scene, cloneDeep(scope));
},
[ACTIONS.SCENE.ENABLE]: async (self, action) => {
await setSceneActive(self, action, true);
},
[ACTIONS.SCENE.DISABLE]: async (self, action) => {
await setSceneActive(self, action, false);
},
[ACTIONS.MESSAGE.SEND]: async (self, action, scope) => {
const textWithVariables = Handlebars.compile(action.text, {
noEscape: true,
Expand Down
10 changes: 10 additions & 0 deletions server/services/mcp/lib/sceneSchemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,16 @@ function createSceneCreateInputSchema(
actionSchemaByType(ACTIONS.SCENE.START, {
scene: sceneSelectorSchema,
}),
actionSchemaByType(ACTIONS.SCENE.ENABLE, {
scene: sceneSelectorSchema.describe(
'Selector of the scene to enable. Its triggers will start listening again. A scene can enable itself.',
),
}),
actionSchemaByType(ACTIONS.SCENE.DISABLE, {
scene: sceneSelectorSchema.describe(
'Selector of the scene to disable. Its triggers stop listening until it is enabled again. A scene can disable itself.',
),
}),
actionSchemaByType(ACTIONS.MESSAGE.SEND, {
user: userSelectorSchema,
text: z.string(),
Expand Down
Loading
Loading