Skip to content

Commit

Permalink
Allows a scene to trigger another scene
Browse files Browse the repository at this point in the history
  • Loading branch information
rob-mccann committed Apr 7, 2021
1 parent 13c875c commit 009b4f5
Show file tree
Hide file tree
Showing 12 changed files with 281 additions and 5 deletions.
4 changes: 4 additions & 0 deletions front/src/config/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,10 @@
"headersLabel": "Headers",
"headersKeyPlaceholder": "Name",
"headersValuePlaceholder": "Value"
},
"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 @@ -933,6 +933,10 @@
"headersLabel": "Headers",
"headersKeyPlaceholder": "Nom",
"headersValuePlaceholder": "Valeur"
},
"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 @@ -32,7 +33,8 @@ const ACTION_ICON = {
[ACTIONS.USER.SET_SEEN_AT_HOME]: 'fe fe-home',
[ACTIONS.USER.SET_OUT_OF_HOME]: 'fe fe-home',
[ACTIONS.HTTP.REQUEST]: 'fe fe-link',
[ACTIONS.USER.CHECK_PRESENCE]: 'fe fe-home'
[ACTIONS.USER.CHECK_PRESENCE]: 'fe fe-home',
[ACTIONS.SCENE.START]: 'fe fe-fast-forward'
};

const ActionCard = ({ children, ...props }) => (
Expand Down Expand Up @@ -185,6 +187,17 @@ const ActionCard = ({ children, ...props }) => (
updateActionProperty={props.updateActionProperty}
/>
)}
{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 @@ -17,7 +17,8 @@ const ACTION_LIST = [
ACTIONS.USER.SET_SEEN_AT_HOME,
ACTIONS.USER.SET_OUT_OF_HOME,
ACTIONS.USER.CHECK_PRESENCE,
ACTIONS.HTTP.REQUEST
ACTIONS.HTTP.REQUEST,
ACTIONS.SCENE.START
];

@connect('httpClient', {})
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 @@ -103,7 +103,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.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 @@ -8,11 +8,15 @@ const logger = require('../../utils/logger');
* @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
123 changes: 122 additions & 1 deletion 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 { ACTIONS } = require('../../../utils/constants');
const SceneManager = require('../../../lib/scene');
Expand All @@ -11,6 +11,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 @@ -42,6 +52,117 @@ describe('SceneManager', () => {
});
});
});
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);
}
});
});
});
it('scene does not exist', async () => {
const sceneManager = new SceneManager(light, event);
return sceneManager.execute('thisscenedoesnotexist');
Expand Down
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 @@ -539,4 +539,52 @@ describe('scene.executeActions', () => {
);
assert.calledWith(message.sendToUser, 'pepper', 'Temperature in the living room is 15 °C.');
});

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);
});
});

0 comments on commit 009b4f5

Please sign in to comment.