Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allows a scene to trigger another scene #1124

Merged
merged 2 commits into from
May 10, 2021
Merged
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
Next Next commit
Allows a scene to trigger another scene
  • Loading branch information
rob-mccann committed Apr 28, 2021
commit 75de79215d961302d612f065c5a1c818567cabda
4 changes: 4 additions & 0 deletions front/src/config/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,10 @@
"responseData": "Response",
"tryButton": "Try request",
"requestError": "An error happened while making the request. Are you sure parameters are right?"
},
"scene": {
"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."
}
},
"actions": {
Expand Down
4 changes: 4 additions & 0 deletions front/src/config/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,10 @@
"responseData": "Réponse",
"tryButton": "Essayer",
"requestError": "Une erreur est survenue lors de la requête. Êtes vous sûr d'avoir bien rempli le formulaire ?"
},
"scene": {
"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"
}
},
"actions": {
Expand Down
15 changes: 14 additions & 1 deletion front/src/routes/scene/edit-scene/ActionCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import SendMessageParams from './actions/SendMessageParams';
import OnlyContinueIfParams from './actions/only-continue-if/OnlyContinueIfParams';
import TurnOnOffLightParams from './actions/TurnOnOffLightParams';
import TurnOnOffSwitchParams from './actions/TurnOnOffSwitchParams';
import StartSceneParams from './actions/StartSceneParams';
import UserPresence from './actions/UserPresence';
import HttpRequest from './actions/HttpRequest';
import CheckUserPresence from './actions/CheckUserPresence';
Expand All @@ -34,7 +35,8 @@ const ACTION_ICON = {
[ACTIONS.USER.SET_OUT_OF_HOME]: 'fe fe-home',
[ACTIONS.HTTP.REQUEST]: 'fe fe-link',
[ACTIONS.USER.CHECK_PRESENCE]: 'fe fe-home',
[ACTIONS.CONDITION.CHECK_TIME]: 'fe fe-watch'
[ACTIONS.CONDITION.CHECK_TIME]: 'fe fe-watch',
[ACTIONS.SCENE.START]: 'fe fe-fast-forward'
};

const ActionCard = ({ children, ...props }) => (
Expand Down Expand Up @@ -197,6 +199,17 @@ const ActionCard = ({ children, ...props }) => (
setVariables={props.setVariables}
/>
)}
{props.action.type === ACTIONS.SCENE.START && (
<StartSceneParams
action={props.action}
columnIndex={props.columnIndex}
index={props.index}
updateActionProperty={props.updateActionProperty}
variables={props.variables}
setVariables={props.setVariables}
scene={props.scene}
/>
)}
</div>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions front/src/routes/scene/edit-scene/ActionGroup.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const ActionGroup = ({ children, ...props }) => (
actionsGroupsBefore={props.actionsGroupsBefore}
variables={props.variables}
setVariables={props.setVariables}
scene={props.scene}
/>
))}
</div>
Expand Down
1 change: 1 addition & 0 deletions front/src/routes/scene/edit-scene/EditScenePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const EditScenePage = ({ children, ...props }) => (
updateActionProperty={props.updateActionProperty}
highLightedActions={props.highLightedActions}
sceneParamsData={props.sceneParamsData}
scene={props.scene}
index={index}
saving={props.saving}
actionsGroupsBefore={update(props.scene.actions, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ const ACTION_LIST = [
ACTIONS.USER.SET_OUT_OF_HOME,
ACTIONS.USER.CHECK_PRESENCE,
ACTIONS.HTTP.REQUEST,
ACTIONS.CONDITION.CHECK_TIME
ACTIONS.CONDITION.CHECK_TIME,
ACTIONS.SCENE.START
];

const TRANSLATIONS = ACTION_LIST.reduce((acc, action) => {
Expand Down
69 changes: 69 additions & 0 deletions front/src/routes/scene/edit-scene/actions/StartSceneParams.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Component } from 'preact';
import { connect } from 'unistore/preact';
import { Text } from 'preact-i18n';
import Select from 'react-select';

import actions from '../../../../actions/scene';

@connect('scenes', actions)
class StartSceneParams extends Component {
handleChange = selectedOption => {
if (selectedOption) {
this.props.updateActionProperty(this.props.columnIndex, this.props.index, 'scene', selectedOption.value);
} else {
this.props.updateActionProperty(this.props.columnIndex, this.props.index, 'scene', null);
}
};

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

if (scenes.length === 0 && nextProps.scenes) {
scenes = nextProps.scenes
.filter(scene => scene.selector !== currentScene)
.map(scene => ({
value: scene.selector,
label: scene.name
}));
}

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 }) {
return (
<div class="form-group">
<div class="alert alert-info">
<Text id="editScene.actionsCard.scene.notice" />
</div>
<label class="form-label">
<Text id="editScene.actionsCard.scene.label" />
</label>
<Select value={selectedOption} onChange={this.handleChange} options={scenes} />
</div>
);
}
}

export default StartSceneParams;
11 changes: 10 additions & 1 deletion server/lib/scene/scene.actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,16 @@ const actionsFunc = {
}
setTimeout(resolve, timeToWaitMilliseconds);
}),
[ACTIONS.SCENE.START]: async (self, action, scope) => self.execute(action.scene, scope),
[ACTIONS.SCENE.START]: async (self, action, scope) => {
if (scope.alreadyExecutedScenes && scope.alreadyExecutedScenes.has(action.scene)) {
logger.info(
`It looks the scene "${action.scene}" has already been triggered in this chain. Preventing running again to avoid loops.`,
);
return;
}

self.execute(action.scene, scope);
},
[ACTIONS.MESSAGE.SEND]: async (self, action, scope) => {
const textWithVariables = Handlebars.compile(action.text)(scope);
await self.message.sendToUser(action.user, textWithVariables);
Expand Down
6 changes: 5 additions & 1 deletion server/lib/scene/scene.execute.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ const { AbortScene } = require('../../utils/coreErrors');
* @example
* sceneManager.execute('test');
*/
function execute(sceneSelector, scope) {
function execute(sceneSelector, scope = {}) {
try {
if (!this.scenes[sceneSelector]) {
throw new Error(`Scene with selector ${sceneSelector} not found.`);
}

scope.alreadyExecutedScenes = scope.alreadyExecutedScenes || new Set();
scope.alreadyExecutedScenes.add(sceneSelector);

this.queue.push(async () => {
try {
await executeActions(this, this.scenes[sceneSelector].actions, scope);
Expand Down
1 change: 1 addition & 0 deletions server/models/scene.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const actionSchema = Joi.array().items(
devices: Joi.array().items(Joi.string()),
user: Joi.string(),
house: Joi.string(),
scene: Joi.string(),
text: Joi.string(),
value: Joi.number(),
minutes: Joi.number(),
Expand Down
126 changes: 124 additions & 2 deletions server/test/lib/scene/scene.execute.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { assert, fake } = require('sinon');
const { assert, fake, createSandbox } = require('sinon');
const EventEmitter = require('events');
const { expect } = require('chai');
const { ACTIONS } = require('../../../utils/constants');
Expand All @@ -12,6 +12,16 @@ const light = {
};

describe('SceneManager', () => {
let sandbox;

beforeEach(() => {
sandbox = createSandbox();
});

afterEach(() => {
sandbox.restore();
});

it('should execute one scene', async () => {
const stateManager = new StateManager(event);
const device = {
Expand Down Expand Up @@ -85,7 +95,8 @@ describe('SceneManager', () => {
sceneManager.queue.start(() => {
try {
assert.notCalled(device.setValue);
expect(scope).to.deep.equal({ '0': { '0': { category: 'light', type: 'binary', last_value: 15 } } });
expect(scope).to.have.property('0');
expect(scope['0']).to.deep.equal({ '0': { category: 'light', type: 'binary', last_value: 15 } });
resolve();
} catch (e) {
reject(e);
Expand Down Expand Up @@ -129,4 +140,115 @@ describe('SceneManager', () => {
const sceneManager = new SceneManager(light, event);
return sceneManager.execute('thisscenedoesnotexist');
});
it('should execute chained scenes', async () => {
const stateManager = new StateManager(event);
const sceneManager = new SceneManager(stateManager, event);

const executeSpy = sandbox.spy(sceneManager, 'execute');
const scope = {};
const scene = {
selector: 'my-scene',
triggers: [],
actions: [
[
{
type: ACTIONS.SCENE.START,
scene: 'second-scene',
},
],
],
};
const secondScene = {
selector: 'second-scene',
triggers: [],
actions: [
[
{
type: ACTIONS.SCENE.START,
scene: 'my-scene',
},
],
],
};
sceneManager.addScene(scene);
sceneManager.addScene(secondScene);
await sceneManager.execute('my-scene', scope);
return new Promise((resolve, reject) => {
sceneManager.queue.start(() => {
try {
assert.calledTwice(executeSpy);
assert.calledWith(executeSpy.firstCall, 'my-scene', scope);
assert.calledWith(executeSpy.secondCall, 'second-scene', scope);
resolve();
} catch (e) {
reject(e);
}
});
});
});

it('should deduplicate calls to other scenes', async () => {
const stateManager = new StateManager(event);
const sceneManager = new SceneManager(stateManager, event);

const executeSpy = sandbox.spy(sceneManager, 'execute');
const scope = {};
const scene = {
selector: 'my-scene',
triggers: [],
actions: [
[
{
type: ACTIONS.SCENE.START,
scene: 'second-scene',
},
{
type: ACTIONS.SCENE.START,
scene: 'second-scene',
},
],
[
{
type: ACTIONS.SCENE.START,
scene: 'second-scene',
},
{
type: ACTIONS.SCENE.START,
scene: 'second-scene',
},
],
],
};
const secondScene = {
selector: 'second-scene',
triggers: [],
actions: [
[
{
type: ACTIONS.SCENE.START,
scene: 'my-scene',
},
{
type: ACTIONS.SCENE.START,
scene: 'my-scene',
},
],
],
};
sceneManager.addScene(scene);
sceneManager.addScene(secondScene);
await sceneManager.execute('my-scene', scope);
return new Promise((resolve, reject) => {
sceneManager.queue.start(() => {
try {
assert.calledTwice(executeSpy);
assert.calledWith(executeSpy.firstCall, 'my-scene', scope);
assert.calledWith(executeSpy.secondCall, 'second-scene', scope);
resolve();
} catch (e) {
reject(e);
}
});
});
});
});
48 changes: 48 additions & 0 deletions server/test/lib/scene/scene.executeActions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -637,4 +637,52 @@ describe('scene.executeActions', () => {
await chaiAssert.isRejected(promise, AbortScene);
clock.restore();
});

it('should execute action scene.start', async () => {
const stateManager = new StateManager(event);

const execute = fake.resolves(undefined);

const scope = {
alreadyExecutedScenes: new Set(),
};

await executeActions(
{ stateManager, event, execute },
[
[
{
type: ACTIONS.SCENE.START,
scene: 'other_scene_selector',
},
],
],
scope,
);
assert.calledWith(execute, 'other_scene_selector', scope);
});

it('should not execute action scene.start when the scene has already been called as part of this chain', async () => {
const stateManager = new StateManager(event);

const execute = fake.resolves(undefined);

const scope = {
alreadyExecutedScenes: new Set(['other_scene_selector']),
};

await executeActions(
{ stateManager, event, execute },
[
[
{
type: ACTIONS.SCENE.START,
scene: 'other_scene_selector',
},
],
],
scope,
);
assert.notCalled(execute);
});
});