Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 8 additions & 1 deletion front/src/config/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -3282,6 +3282,11 @@
"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",
"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 +3400,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
9 changes: 8 additions & 1 deletion front/src/config/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3282,6 +3282,11 @@
"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",
"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 +3400,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
9 changes: 8 additions & 1 deletion front/src/config/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -3282,6 +3282,11 @@
"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",
"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 +3400,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,76 @@
import { Component } from 'preact';
import { connect } from 'unistore/preact';
import { Text } 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) {
scenes = nextProps.scenes.map(scene => ({
value: scene.selector,
label: scene.name
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}));
}

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 connect('scenes', actions)(EnableDisableSceneParams);
33 changes: 32 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,31 @@ 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');
}
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 +352,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
146 changes: 146 additions & 0 deletions server/test/lib/scene/actions/scene.action.enableDisableScene.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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 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');
});
});
Loading
Loading