diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json
index 4703487ea2..4a0aa80f9f 100644
--- a/front/src/config/i18n/de.json
+++ b/front/src/config/i18n/de.json
@@ -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.",
@@ -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",
diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json
index 3080417573..3eab41b298 100644
--- a/front/src/config/i18n/en.json
+++ b/front/src/config/i18n/en.json
@@ -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.",
@@ -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",
diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json
index 152327809a..e0d77a2658 100644
--- a/front/src/config/i18n/fr.json
+++ b/front/src/config/i18n/fr.json
@@ -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.",
@@ -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",
diff --git a/front/src/routes/scene/edit-scene/ActionCard.jsx b/front/src/routes/scene/edit-scene/ActionCard.jsx
index 9690a4411a..6e8163e255 100644
--- a/front/src/routes/scene/edit-scene/ActionCard.jsx
+++ b/front/src/routes/scene/edit-scene/ActionCard.jsx
@@ -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';
@@ -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',
@@ -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,
diff --git a/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx b/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx
index 72e21506cf..1b99258f90 100644
--- a/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx
+++ b/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx
@@ -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,
diff --git a/front/src/routes/scene/edit-scene/actions/EnableDisableSceneParams.jsx b/front/src/routes/scene/edit-scene/actions/EnableDisableSceneParams.jsx
new file mode 100644
index 0000000000..9ecc920089
--- /dev/null
+++ b/front/src/routes/scene/edit-scene/actions/EnableDisableSceneParams.jsx
@@ -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 (
+
+ );
+ }
+}
+
+export default withText({
+ disabledSceneLabel: 'editScene.actionsCard.setSceneActive.disabledScene'
+})(connect('scenes', actions)(EnableDisableSceneParams));
diff --git a/server/lib/scene/scene.actions.js b/server/lib/scene/scene.actions.js
index 0d420c4f68..c67de2e908 100644
--- a/server/lib/scene/scene.actions.js
+++ b/server/lib/scene/scene.actions.js
@@ -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');
@@ -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 });
+ } 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;
@@ -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,
diff --git a/server/services/mcp/lib/sceneSchemas.js b/server/services/mcp/lib/sceneSchemas.js
index de00d0713a..81301cf729 100644
--- a/server/services/mcp/lib/sceneSchemas.js
+++ b/server/services/mcp/lib/sceneSchemas.js
@@ -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(),
diff --git a/server/test/lib/scene/actions/scene.action.enableDisableScene.test.js b/server/test/lib/scene/actions/scene.action.enableDisableScene.test.js
new file mode 100644
index 0000000000..52013d1260
--- /dev/null
+++ b/server/test/lib/scene/actions/scene.action.enableDisableScene.test.js
@@ -0,0 +1,184 @@
+const sinon = require('sinon').createSandbox();
+
+const { fake, assert } = sinon;
+const EventEmitter = require('events');
+const chai = require('chai');
+const chaiAsPromised = require('chai-as-promised');
+
+const { ACTIONS } = require('../../../../utils/constants');
+const executeActionsFactory = require('../../../../lib/scene/scene.executeActions');
+const actionsFunc = require('../../../../lib/scene/scene.actions');
+const { AbortScene, NotFoundError } = require('../../../../utils/coreErrors');
+
+const StateManager = require('../../../../lib/state');
+const SceneManager = require('../../../../lib/scene');
+const db = require('../../../../models');
+
+chai.use(chaiAsPromised);
+
+const { expect } = chai;
+
+describe('scene.enable / scene.disable', () => {
+ const { executeActions } = executeActionsFactory(actionsFunc);
+ let event;
+ let stateManager;
+
+ beforeEach(() => {
+ event = new EventEmitter();
+ stateManager = new StateManager(event);
+ });
+
+ afterEach(() => {
+ sinon.reset();
+ });
+
+ it('should enable another scene', async () => {
+ const update = fake.resolves({ selector: 'my-other-scene', active: true });
+ const scope = {};
+ await executeActions(
+ { stateManager, event, update },
+ [
+ [
+ {
+ type: ACTIONS.SCENE.ENABLE,
+ scene: 'my-other-scene',
+ },
+ ],
+ ],
+ scope,
+ );
+ assert.calledOnceWithExactly(update, 'my-other-scene', { active: true });
+ });
+
+ it('should disable another scene', async () => {
+ const update = fake.resolves({ selector: 'my-other-scene', active: false });
+ const scope = {};
+ await executeActions(
+ { stateManager, event, update },
+ [
+ [
+ {
+ type: ACTIONS.SCENE.DISABLE,
+ scene: 'my-other-scene',
+ },
+ ],
+ ],
+ scope,
+ );
+ assert.calledOnceWithExactly(update, 'my-other-scene', { active: false });
+ });
+
+ it('should allow a scene to disable itself', async () => {
+ const update = fake.resolves({ selector: 'my-scene', active: false });
+ const scope = {};
+ await executeActions(
+ { stateManager, event, update },
+ [
+ [
+ {
+ type: ACTIONS.SCENE.DISABLE,
+ scene: 'my-scene',
+ },
+ ],
+ ],
+ scope,
+ );
+ assert.calledOnceWithExactly(update, 'my-scene', { active: false });
+ });
+
+ it('should not update the scene when it is already in the requested state', async () => {
+ // Updating a scene cancels and re-schedules all its triggers, so re-enabling an already
+ // enabled scene would restart its interval jobs on every run and it could never fire.
+ const update = fake.resolves(null);
+ const scenes = { 'my-other-scene': { selector: 'my-other-scene', active: true } };
+ await executeActions(
+ { stateManager, event, update, scenes },
+ [
+ [
+ {
+ type: ACTIONS.SCENE.ENABLE,
+ scene: 'my-other-scene',
+ },
+ ],
+ ],
+ {},
+ );
+ assert.notCalled(update);
+ });
+
+ it('should update the scene when the requested state is different', async () => {
+ const update = fake.resolves(null);
+ const scenes = { 'my-other-scene': { selector: 'my-other-scene', active: true } };
+ await executeActions(
+ { stateManager, event, update, scenes },
+ [
+ [
+ {
+ type: ACTIONS.SCENE.DISABLE,
+ scene: 'my-other-scene',
+ },
+ ],
+ ],
+ {},
+ );
+ assert.calledOnceWithExactly(update, 'my-other-scene', { active: false });
+ });
+
+ it('should abort the scene when no scene is selected', async () => {
+ const update = fake.resolves(null);
+ const scope = {};
+ const promise = executeActions(
+ { stateManager, event, update },
+ [
+ [
+ {
+ type: ACTIONS.SCENE.ENABLE,
+ },
+ ],
+ ],
+ scope,
+ );
+ await expect(promise).to.be.rejectedWith(AbortScene, 'SCENE_NOT_FOUND');
+ assert.notCalled(update);
+ });
+
+ it('should abort the scene when the selected scene does not exist anymore', async () => {
+ const update = fake.rejects(new NotFoundError('Scene not found'));
+ const scope = {};
+ const promise = executeActions(
+ { stateManager, event, update },
+ [
+ [
+ {
+ type: ACTIONS.SCENE.DISABLE,
+ scene: 'deleted-scene',
+ },
+ ],
+ ],
+ scope,
+ );
+ await expect(promise).to.be.rejectedWith(AbortScene, 'SCENE_NOT_FOUND');
+ assert.calledOnceWithExactly(update, 'deleted-scene', { active: false });
+ });
+
+ it('should persist the active flag of the target scene in database', async () => {
+ const brain = { addNamedEntity: fake.returns(null), removeNamedEntity: fake.returns(null) };
+ const sceneManager = new SceneManager({}, event, {}, {}, {}, {}, {}, {}, {}, {}, brain);
+ await executeActions(sceneManager, [[{ type: ACTIONS.SCENE.DISABLE, scene: 'test-scene' }]], {});
+ let sceneInDb = await db.Scene.findOne({ where: { selector: 'test-scene' } });
+ expect(sceneInDb.active).to.equal(false);
+ await executeActions(sceneManager, [[{ type: ACTIONS.SCENE.ENABLE, scene: 'test-scene' }]], {});
+ sceneInDb = await db.Scene.findOne({ where: { selector: 'test-scene' } });
+ expect(sceneInDb.active).to.equal(true);
+ });
+
+ it('should propagate an unexpected error coming from the update', async () => {
+ const update = fake.rejects(new Error('DATABASE_ERROR'));
+ const promise = actionsFunc[ACTIONS.SCENE.ENABLE](
+ { stateManager, event, update },
+ { type: ACTIONS.SCENE.ENABLE, scene: 'my-other-scene' },
+ {},
+ );
+ await expect(promise).to.be.rejectedWith(Error, 'DATABASE_ERROR');
+ });
+});
diff --git a/server/test/services/mcp/lib/sceneSchemas.test.js b/server/test/services/mcp/lib/sceneSchemas.test.js
index fa72c29a3c..01505c6484 100644
--- a/server/test/services/mcp/lib/sceneSchemas.test.js
+++ b/server/test/services/mcp/lib/sceneSchemas.test.js
@@ -33,6 +33,20 @@ describe('sceneSchemas helpers', () => {
expect(result.success).to.equal(false);
});
+ it('should accept scene.enable and scene.disable actions', () => {
+ const schema = createSceneCreateInputSchema();
+ const result = schema.safeParse({
+ name: 'My scene',
+ icon: 'lightbulb',
+ triggers: [{ type: 'system.start' }],
+ actions: [
+ [{ type: ACTIONS.SCENE.ENABLE, scene: 'my-other-scene' }],
+ [{ type: ACTIONS.SCENE.DISABLE, scene: 'my-scene' }],
+ ],
+ });
+ expect(result.success).to.equal(true);
+ });
+
it('should flatten nested scene actions and ignore invalid entries', () => {
expect(flattenSceneActions(null)).to.deep.equal([]);
expect(flattenSceneActions('invalid')).to.deep.equal([]);
diff --git a/server/utils/constants.js b/server/utils/constants.js
index a266424629..f52965969c 100644
--- a/server/utils/constants.js
+++ b/server/utils/constants.js
@@ -701,6 +701,8 @@ const ACTIONS = {
},
SCENE: {
START: 'scene.start',
+ ENABLE: 'scene.enable',
+ DISABLE: 'scene.disable',
},
MESSAGE: {
SEND: 'message.send',