diff --git a/front/package-lock.json b/front/package-lock.json index 10f7191b72..93337684d3 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -13271,9 +13271,9 @@ } }, "preact": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.2.1.tgz", - "integrity": "sha512-BUNvmQcVtNElku7mYHexIiM5JlGNSW2BY9O2t9xk1NqA43O8wbE0cah6PAlvT7PBHvyDRJ5TAj5Fewdi9DqLoQ==" + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.3.2.tgz", + "integrity": "sha512-yIx4i7gp45enhzX4SLkvvR20UZ+YOUbMdj2KEscU/dC70MHv/L6dpTcsP+4sXrU9SRbA3GjJQQCPfFa5sE17dQ==" }, "preact-cli": { "version": "2.2.1", diff --git a/front/package.json b/front/package.json index 3a9bd485ea..bb0dcc650a 100644 --- a/front/package.json +++ b/front/package.json @@ -38,7 +38,7 @@ "leaflet": "^1.4.0", "linkstate": "^1.1.1", "moment": "^2.24.0", - "preact": "^10.2.1", + "preact": "^10.3.2", "preact-cli-plugin-fast-async": "^1.0.1", "preact-i18n": "^2.0.0-preactx.2", "preact-router": "^3.2.1", diff --git a/front/src/actions/createScene.js b/front/src/actions/createScene.js index b5e22cb3d4..c78f01ec7a 100644 --- a/front/src/actions/createScene.js +++ b/front/src/actions/createScene.js @@ -10,6 +10,9 @@ function createActions(store) { if (!state.newScene.name) { newSceneErrors.name = true; } + if (state.newScene.name === 'new') { + newSceneErrors.name = true; + } if (!state.newScene.icon) { newSceneErrors.icon = true; } diff --git a/front/src/actions/scene.js b/front/src/actions/scene.js index d18b35031d..687d233a23 100644 --- a/front/src/actions/scene.js +++ b/front/src/actions/scene.js @@ -1,7 +1,6 @@ import { RequestStatus } from '../utils/consts'; import update, { extend } from 'immutability-helper'; import debounce from 'debounce'; -import { route } from 'preact-router'; extend('$auto', function(value, object) { return object ? update(object, value) : update({}, value); @@ -43,204 +42,6 @@ function createActions(store) { getScenesOrderDir: e.target.value }); await actions.getScenes(store.getState()); - }, - async getSceneBySelector(state, sceneSelector) { - store.setState({ - SceneGetStatus: RequestStatus.Getting - }); - try { - const scene = await state.httpClient.get(`/api/v1/scene/${sceneSelector}`); - if (scene.actions[scene.actions.length - 1].length > 0) { - scene.actions.push([]); - } - if (!scene.triggers) { - scene.triggers = []; - } - store.setState({ - scene, - SceneGetStatus: RequestStatus.Success - }); - } catch (e) { - store.setState({ - SceneGetStatus: RequestStatus.Error - }); - } - }, - async startScene(state, selector) { - store.setState({ - SceneStartStatus: RequestStatus.Getting - }); - try { - await state.httpClient.post(`/api/v1/scene/${selector}/start`); - store.setState({ - SceneStartStatus: RequestStatus.Success - }); - } catch (e) { - store.setState({ - SceneStartStatus: RequestStatus.Error - }); - } - }, - async saveScene(state) { - await state.httpClient.patch(`/api/v1/scene/${state.scene.selector}`, state.scene); - }, - addAction(state, columnIndex) { - let newState = update(state, { - scene: { - actions: { - [columnIndex]: { - $push: [ - { - type: null - } - ] - } - } - } - }); - if (state.scene.actions[columnIndex].length === 0) { - newState = update(newState, { - scene: { - actions: { - $push: [[]] - } - } - }); - } - store.setState(newState); - }, - deleteAction(state, columnIndex, rowIndex) { - let newState = update(state, { - scene: { - actions: { - [columnIndex]: { - $splice: [[rowIndex, 1]] - } - } - } - }); - // if necessary, we remove the last action group - if (newState.scene.actions.length >= 2) { - if ( - newState.scene.actions[newState.scene.actions.length - 1].length === 0 && - newState.scene.actions[newState.scene.actions.length - 2].length === 0 - ) { - newState = update(newState, { - scene: { - actions: { - $splice: [[newState.scene.actions.length - 1, 1]] - } - } - }); - } - } - store.setState(newState); - }, - updateActionProperty(state, columnIndex, rowIndex, property, value) { - const newState = update(state, { - scene: { - actions: { - [columnIndex]: { - [rowIndex]: { - [property]: { - $set: value - } - } - } - } - } - }); - store.setState(newState); - }, - highlighCurrentlyExecutedAction(state, { columnIndex, rowIndex }) { - store.setState({ - highLightedActions: { - [`${columnIndex}:${rowIndex}`]: true - } - }); - }, - removeHighlighAction(state, { columnIndex, rowIndex }) { - setTimeout(() => { - store.setState({ - highLightedActions: { - [`${columnIndex}:${rowIndex}`]: false - } - }); - }, 500); - }, - async getUsers(state) { - store.setState({ - GetUsersStatus: RequestStatus.Getting - }); - try { - const users = await state.httpClient.get(`/api/v1/user`); - const sceneParamsData = { - users - }; - store.setState({ - sceneParamsData, - GetUsersStatus: RequestStatus.Success - }); - } catch (e) { - store.setState({ - GetUsersStatus: RequestStatus.Error - }); - } - }, - async deleteScene(state, selector) { - store.setState({ - deleteSceneStatus: RequestStatus.Getting - }); - try { - await state.httpClient.delete(`/api/v1/scene/${selector}`); - store.setState({ - deleteSceneStatus: RequestStatus.Success - }); - route('/dashboard/scene'); - } catch (e) { - store.setState({ - GetUsersStatus: RequestStatus.Error - }); - } - }, - addTrigger(state) { - const newState = update(state, { - scene: { - triggers: { - $push: [ - { - type: null - } - ] - } - } - }); - store.setState(newState); - }, - deleteTrigger(state, index) { - const newState = update(state, { - scene: { - triggers: { - $splice: [[index, 1]] - } - } - }); - store.setState(newState); - }, - updateTriggerProperty(state, index, property, value) { - console.log({ index, property, value }); - const newState = update(state, { - scene: { - triggers: { - [index]: { - [property]: { - $set: value - } - } - } - } - }); - store.setState(newState); } }; actions.debouncedSearch = debounce(actions.search, 200); diff --git a/front/src/components/device/SelectDeviceFeature.jsx b/front/src/components/device/SelectDeviceFeature.jsx new file mode 100644 index 0000000000..00ebb92772 --- /dev/null +++ b/front/src/components/device/SelectDeviceFeature.jsx @@ -0,0 +1,112 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import Select from 'react-select'; + +import { getDeviceFeatureName } from '../../utils/device'; + +@connect('httpClient', {}) +class SelectDeviceFeature extends Component { + getOptions = async () => { + try { + // we get the rooms with the devices + const rooms = await this.props.httpClient.get('/api/v1/room?expand=devices'); + const deviceOptions = []; + + const deviceDictionnary = {}; + const deviceFeaturesDictionnary = {}; + + // and compose the multi-level options + rooms.forEach(room => { + const roomDeviceFeatures = []; + room.devices.forEach(device => { + device.features.forEach(feature => { + // keep device / deviceFeature in dictionnary + deviceFeaturesDictionnary[feature.selector] = feature; + deviceDictionnary[feature.selector] = device; + + roomDeviceFeatures.push({ + value: feature.selector, + label: getDeviceFeatureName(this.context.intl.dictionary, device, feature) + }); + }); + }); + if (roomDeviceFeatures.length > 0) { + roomDeviceFeatures.sort((a, b) => { + if (a.label < b.label) { + return -1; + } else if (a.label > b.label) { + return 1; + } + return 0; + }); + deviceOptions.push({ + label: room.name, + options: roomDeviceFeatures + }); + } + }); + await this.setState({ deviceOptions, deviceFeaturesDictionnary, deviceDictionnary }); + await this.refreshSelectedOptions(this.props); + if (this.state.selectedOption && this.state.selectedOption.value) { + this.props.onDeviceFeatureChange( + deviceFeaturesDictionnary[this.state.selectedOption.value], + deviceDictionnary[this.state.selectedOption.value] + ); + } + return deviceOptions; + } catch (e) { + console.log(e); + } + }; + handleChange = selectedOption => { + const { deviceFeaturesDictionnary, deviceDictionnary } = this.state; + if (selectedOption && selectedOption.value) { + this.props.onDeviceFeatureChange( + deviceFeaturesDictionnary[selectedOption.value], + deviceDictionnary[selectedOption.value] + ); + } else { + this.props.onDeviceFeatureChange(null); + } + }; + refreshSelectedOptions = async nextProps => { + let selectedOption = ''; + if (nextProps.value && this.state.deviceOptions) { + let deviceOption; + let i = 0; + while (i < this.state.deviceOptions.length && deviceOption === undefined) { + deviceOption = this.state.deviceOptions[i].options.find(option => option.value === nextProps.value); + i++; + } + + if (deviceOption) { + selectedOption = deviceOption; + } + } + await this.setState({ selectedOption }); + }; + constructor(props) { + super(props); + this.state = { + deviceOptions: null, + selectedOption: '' + }; + } + + async componentDidMount() { + this.getOptions(); + } + + componentWillReceiveProps(nextProps) { + this.refreshSelectedOptions(nextProps); + } + + render(props, { selectedOption, deviceOptions }) { + if (!deviceOptions) { + return null; + } + return { + const newCondition = update(this.props.condition, { + variable: { + $set: selectedOption && selectedOption.value ? selectedOption.value : null + } + }); + this.props.handleConditionChange(this.props.index, newCondition); + }; + + handleOperatorChange = e => { + const newCondition = update(this.props.condition, { + operator: { + $set: e.target.value + } + }); + this.props.handleConditionChange(this.props.index, newCondition); + }; + + handleValueChange = e => { + const newValue = Number.isInteger(parseInt(e.target.value, 10)) ? parseInt(e.target.value, 10) : null; + const newCondition = update(this.props.condition, { + value: { + $set: newValue + } + }); + this.props.handleConditionChange(this.props.index, newCondition); + }; + + deleteCondition = () => { + this.props.deleteCondition(this.props.index); + }; + + getSelectedOption = () => { + let selectedOption = null; + + this.props.variableOptions.forEach(variableOption => { + const foundOption = variableOption.options.find(option => this.props.condition.variable === option.value); + if (foundOption) { + selectedOption = foundOption; + } + }); + + return selectedOption; + }; + + render(props, {}) { + const selectedOption = this.getSelectedOption(); + return ( +
+
+
+
+ + + + + + + + + + +
+
+
+
+ + + } + value={props.condition.value} + onChange={this.handleValueChange} + /> + +
+
+
+ {props.index > 0 && ( +
+ + +
+ )} +
+
+
+
+ {props.lastOne && ( + + )} + {!props.lastOne && ( +

+ +

+ )} +
+
+
+ ); + } +} + +export default Condition; diff --git a/front/src/routes/scene/edit-scene/actions/only-continue-if/OnlyContinueIfParams.jsx b/front/src/routes/scene/edit-scene/actions/only-continue-if/OnlyContinueIfParams.jsx new file mode 100644 index 0000000000..0b1db3145e --- /dev/null +++ b/front/src/routes/scene/edit-scene/actions/only-continue-if/OnlyContinueIfParams.jsx @@ -0,0 +1,81 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import update from 'immutability-helper'; +import get from 'get-value'; + +import Condition from './Condition'; + +@connect('httpClient', {}) +class OnlyContinueIf extends Component { + handleConditionChange = (conditionIndex, condition) => { + const { columnIndex, index } = this.props; + const newConditions = update(this.props.action.conditions, { + [conditionIndex]: { + $set: condition + } + }); + this.props.updateActionProperty(columnIndex, index, 'conditions', newConditions); + }; + + addCondition = () => { + const { columnIndex, index } = this.props; + const newConditions = update(this.props.action.conditions, { + $push: [{}] + }); + this.props.updateActionProperty(columnIndex, index, 'conditions', newConditions); + }; + + deleteCondition = conditionIndex => { + const { columnIndex, index } = this.props; + const newConditions = update(this.props.action.conditions, { + $splice: [[conditionIndex, 1]] + }); + this.props.updateActionProperty(columnIndex, index, 'conditions', newConditions); + }; + + componentDidMount() { + const { columnIndex, index } = this.props; + if (!this.props.action.conditions) { + this.props.updateActionProperty(columnIndex, index, 'conditions', [{}]); + } + } + + render(props, { selectedOptions, deviceOptions }) { + const variableOptions = []; + + props.actionsGroupsBefore.forEach((actionGroup, groupIndex) => { + actionGroup.forEach((action, index) => { + if (this.props.variables[groupIndex][index]) { + variableOptions.push({ + label: `${groupIndex + 1}. ${get(this, `context.intl.dictionary.editScene.actions.${action.type}`)}`, + options: this.props.variables[groupIndex][index].map(option => ({ + label: option.label, + value: `${groupIndex}.${index}.${option.name}`, + type: option.type, + data: option.data + })) + }); + } + }); + }); + + return ( +
+ {props.action.conditions && + props.action.conditions.map((condition, index) => ( + + ))} +
+ ); + } +} + +export default OnlyContinueIf; diff --git a/front/src/routes/scene/edit-scene/index.js b/front/src/routes/scene/edit-scene/index.js index bb6b81b06d..622f73dca2 100644 --- a/front/src/routes/scene/edit-scene/index.js +++ b/front/src/routes/scene/edit-scene/index.js @@ -1,49 +1,273 @@ import { Component } from 'preact'; import { connect } from 'unistore/preact'; +import update from 'immutability-helper'; +import { route } from 'preact-router'; + +import { RequestStatus } from '../../../utils/consts'; import EditScenePage from './EditScenePage'; -import actions from '../../../actions/scene'; -@connect('session,sceneParamsData,scene,highLightedActions', actions) +@connect('session,httpClient', {}) class EditScene extends Component { - startScene = () => { - this.props.startScene(this.props.scene_selector); + getSceneBySelector = async () => { + this.setState({ + SceneGetStatus: RequestStatus.Getting + }); + try { + const scene = await this.props.httpClient.get(`/api/v1/scene/${this.props.scene_selector}`); + if (scene.actions[scene.actions.length - 1].length > 0) { + scene.actions.push([]); + } + if (!scene.triggers) { + scene.triggers = []; + } + const variables = []; + scene.actions.forEach(actionGroup => { + variables.push(actionGroup.map(action => [])); + }); + this.setState({ + scene, + variables, + SceneGetStatus: RequestStatus.Success + }); + } catch (e) { + this.setState({ + SceneGetStatus: RequestStatus.Error + }); + } + }; + startScene = async () => { + this.setState({ saving: true }); + try { + await this.props.httpClient.post(`/api/v1/scene/${this.props.scene_selector}/start`); + this.setState({ saving: false }); + } catch (e) { + this.setState({ saving: false }); + } }; saveScene = async () => { this.setState({ saving: true, error: false }); try { - await this.props.saveScene(); + await this.props.httpClient.patch(`/api/v1/scene/${this.props.scene_selector}`, this.state.scene); } catch (e) { console.log(e); this.setState({ error: true }); } this.setState({ saving: false }); }; - deleteScene = () => { + addAction = columnIndex => { + this.setState(prevState => { + let newState = update(prevState, { + scene: { + actions: { + [columnIndex]: { + $push: [ + { + type: null + } + ] + } + } + }, + variables: { + [columnIndex]: { + $push: [[]] + } + } + }); + if (newState.scene.actions[columnIndex].length === 0) { + newState = update(newState, { + scene: { + actions: { + $push: [[]] + } + }, + variables: { + $push: [[]] + } + }); + } + return newState; + }); + }; + deleteAction = (columnIndex, rowIndex) => { + this.setState(prevState => { + let newState = update(prevState, { + scene: { + actions: { + [columnIndex]: { + $splice: [[rowIndex, 1]] + } + } + }, + variables: { + [columnIndex]: { + $splice: [[rowIndex, 1]] + } + } + }); + // if necessary, we remove the last action group + if (newState.scene.actions.length >= 2) { + if ( + newState.scene.actions[newState.scene.actions.length - 1].length === 0 && + newState.scene.actions[newState.scene.actions.length - 2].length === 0 + ) { + newState = update(newState, { + scene: { + actions: { + $splice: [[newState.scene.actions.length - 1, 1]] + } + }, + variables: { + $splice: [[newState.scene.actions.length - 1, 1]] + } + }); + } + } + + return newState; + }); + }; + updateActionProperty = (columnIndex, rowIndex, property, value) => { + this.setState(prevState => { + const newState = update(prevState, { + scene: { + actions: { + [columnIndex]: { + [rowIndex]: { + [property]: { + $set: value + } + } + } + } + } + }); + return newState; + }); + }; + highlighCurrentlyExecutedAction = ({ columnIndex, rowIndex }) => { + this.setState({ + highLightedActions: { + [`${columnIndex}:${rowIndex}`]: true + } + }); + }; + removeHighlighAction = ({ columnIndex, rowIndex }) => { + setTimeout(() => { + this.setState({ + highLightedActions: { + [`${columnIndex}:${rowIndex}`]: false + } + }); + }, 500); + }; + deleteScene = async () => { this.setState({ saving: true }); - this.props.deleteScene(this.props.scene_selector); - this.setState({ saving: false }); + try { + await this.props.httpClient.delete(`/api/v1/scene/${this.props.scene_selector}`); + this.setState({ saving: false }); + route('/dashboard/scene'); + } catch (e) { + this.setState({ saving: false }); + } + }; + addTrigger = () => { + this.setState(prevState => { + const newState = update(prevState, { + scene: { + triggers: { + $push: [ + { + type: null + } + ] + } + } + }); + return newState; + }); + }; + deleteTrigger = index => { + this.setState(prevState => { + const newState = update(prevState, { + scene: { + triggers: { + $splice: [[index, 1]] + } + } + }); + return newState; + }); }; - componentWillMount() { - this.props.getSceneBySelector(this.props.scene_selector); - this.props.getUsers(); + updateTriggerProperty = (index, property, value) => { + this.setState(prevState => { + const newState = update(prevState, { + scene: { + triggers: { + [index]: { + [property]: { + $set: value + } + } + } + } + }); + return newState; + }); + }; + + setVariables = (columnIndex, index, variables) => { + this.setState(prevState => { + const newState = update(prevState, { + variables: { + [columnIndex]: { + [index]: { + $set: variables + } + } + } + }); + return newState; + }); + }; + + constructor(props) { + super(props); + this.state = { + scene: null, + variables: {} + }; + } + + componentDidMount() { + this.getSceneBySelector(); this.props.session.dispatcher.addListener('scene.executing-action', payload => - this.props.highlighCurrentlyExecutedAction(payload) + this.highlighCurrentlyExecutedAction(payload) ); this.props.session.dispatcher.addListener('scene.finished-executing-action', payload => - this.props.removeHighlighAction(payload) + this.removeHighlighAction(payload) ); } - render(props, { saving, error }) { + render(props, { saving, error, variables, scene }) { return ( - props.scene && ( + scene && ( ) ); diff --git a/front/src/routes/scene/edit-scene/triggers/DeviceFeatureState.jsx b/front/src/routes/scene/edit-scene/triggers/DeviceFeatureState.jsx index 92149dfd7d..4b96788a25 100644 --- a/front/src/routes/scene/edit-scene/triggers/DeviceFeatureState.jsx +++ b/front/src/routes/scene/edit-scene/triggers/DeviceFeatureState.jsx @@ -1,57 +1,23 @@ import { Component } from 'preact'; import { connect } from 'unistore/preact'; import { Text, Localizer } from 'preact-i18n'; -import Select from 'react-select'; import cx from 'classnames'; +import SelectDeviceFeature from '../../../../components/device/SelectDeviceFeature'; + @connect('httpClient', {}) class TurnOnLight extends Component { - getOptions = async () => { - try { - const rooms = await this.props.httpClient.get('/api/v1/room?expand=devices'); - const deviceOptions = []; - rooms.forEach(room => { - const roomDeviceFeatures = []; - room.devices.forEach(device => { - device.features.forEach(feature => { - roomDeviceFeatures.push({ - value: feature.selector, - label: feature.name, - type: feature.type, - unit: feature.unit - }); - }); - }); - if (roomDeviceFeatures.length > 0) { - roomDeviceFeatures.sort((a, b) => { - if (a.label < b.label) { - return -1; - } else if (a.label > b.label) { - return 1; - } - return 0; - }); - deviceOptions.push({ - label: room.name, - options: roomDeviceFeatures - }); - } - }); - await this.setState({ deviceOptions }); - this.refreshSelectedOptions(this.props); - return deviceOptions; - } catch (e) { - console.log(e); - } - }; - handleChange = selectedOption => { - if (selectedOption && selectedOption.value) { - this.props.updateTriggerProperty(this.props.index, 'device_feature', selectedOption.value); - this.props.updateTriggerProperty(this.props.index, 'value', null); + onDeviceFeatureChange = deviceFeature => { + this.setState({ selectedDeviceFeature: deviceFeature }); + if (deviceFeature) { + this.props.updateTriggerProperty(this.props.index, 'device_feature', deviceFeature.selector); + if (deviceFeature.selector !== this.props.trigger.device_feature) { + this.props.updateTriggerProperty(this.props.index, 'value', null); + } } else { this.props.updateTriggerProperty(this.props.index, 'device_feature', null); } - if (selectedOption && selectedOption.type === 'binary') { + if (deviceFeature && deviceFeature.type === 'binary') { this.props.updateTriggerProperty(this.props.index, 'operator', '='); } }; @@ -68,24 +34,6 @@ class TurnOnLight extends Component { handleValueChangeBinary = newValue => () => { this.props.updateTriggerProperty(this.props.index, 'value', newValue); }; - refreshSelectedOptions = nextProps => { - let selectedOption = ''; - if (nextProps.trigger.device_feature && this.state.deviceOptions) { - let deviceOption; - let i = 0; - while (i < this.state.deviceOptions.length && deviceOption === undefined) { - deviceOption = this.state.deviceOptions[i].options.find( - option => option.value === nextProps.trigger.device_feature - ); - i++; - } - - if (deviceOption) { - selectedOption = deviceOption; - } - } - this.setState({ selectedOption }); - }; getBinaryOperator = () => (
@@ -125,32 +73,22 @@ class TurnOnLight extends Component {
); - constructor(props) { - super(props); - this.state = { - deviceOptions: null, - selectedOption: '' - }; - } - async componentDidMount() { - this.getOptions(); - } - componentWillReceiveProps(nextProps) { - this.refreshSelectedOptions(nextProps); - } - render(props, { selectedOption, deviceOptions }) { + render(props, { selectedDeviceFeature }) { return (
- @@ -177,7 +115,7 @@ class TurnOnLight extends Component {
)} - {selectedOption && selectedOption.type !== 'binary' && ( + {selectedDeviceFeature && selectedDeviceFeature.type !== 'binary' && (
@@ -190,10 +128,10 @@ class TurnOnLight extends Component { onChange={this.handleValueChange} /> - {selectedOption && selectedOption.unit && ( + {selectedDeviceFeature.unit && ( - + )} diff --git a/front/src/utils/device.js b/front/src/utils/device.js new file mode 100644 index 0000000000..55a15f9278 --- /dev/null +++ b/front/src/utils/device.js @@ -0,0 +1,8 @@ +import get from 'get-value'; + +const getDeviceFeatureName = (dictionnary, device, deviceFeature) => { + const featureDescription = get(dictionnary, `deviceFeatureCategory.${deviceFeature.category}.${deviceFeature.type}`); + return `${device.name} (${featureDescription})`; +}; + +export { getDeviceFeatureName }; diff --git a/server/lib/scene/scene.actions.js b/server/lib/scene/scene.actions.js index c35be6fcc0..eb64ec7bc9 100644 --- a/server/lib/scene/scene.actions.js +++ b/server/lib/scene/scene.actions.js @@ -1,10 +1,12 @@ const Promise = require('bluebird'); const { ACTIONS, DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../utils/constants'); const { getDeviceFeature } = require('../../utils/device'); +const { AbortScene } = require('../../utils/coreErrors'); +const { compare } = require('../../utils/compare'); const logger = require('../../utils/logger'); const actionsFunc = { - [ACTIONS.DEVICE.SET_VALUE]: async (self, action, scope) => { + [ACTIONS.DEVICE.SET_VALUE]: async (self, action, scope, columnIndex, rowIndex) => { let device; let deviceFeature; if (action.device_feature) { @@ -31,6 +33,21 @@ const actionsFunc = { } }); }, + [ACTIONS.LIGHT.TURN_OFF]: async (self, action, scope) => { + await Promise.map(action.devices, async (deviceSelector) => { + try { + const device = self.stateManager.get('device', deviceSelector); + const deviceFeature = getDeviceFeature( + device, + DEVICE_FEATURE_CATEGORIES.LIGHT, + DEVICE_FEATURE_TYPES.LIGHT.BINARY, + ); + await self.device.setValue(device, deviceFeature, 0); + } catch (e) { + logger.warn(e); + } + }); + }, [ACTIONS.TIME.DELAY]: async (self, action, scope) => new Promise((resolve) => { let timeToWaitMilliseconds; @@ -58,6 +75,26 @@ const actionsFunc = { [ACTIONS.MESSAGE.SEND]: async (self, action, scope) => { await self.message.sendToUser(action.user, action.text); }, + [ACTIONS.DEVICE.GET_VALUE]: async (self, action, scope, columnIndex, rowIndex) => { + const deviceFeature = self.stateManager.get('deviceFeature', action.device_feature); + scope[`${columnIndex}.${rowIndex}.last_value`] = deviceFeature.last_value; + }, + [ACTIONS.CONDITION.ONLY_CONTINUE_IF]: async (self, action, scope) => { + let oneConditionVerified = false; + action.conditions.forEach((condition) => { + const conditionVerified = compare(condition.operator, scope[condition.variable], condition.value); + if (conditionVerified) { + oneConditionVerified = true; + } else { + logger.debug( + `Condition not verified. Condition = ${scope[condition.variable]} ${condition.operator} ${condition.value}`, + ); + } + }); + if (oneConditionVerified === false) { + throw new AbortScene('CONDITION_NOT_VERIFIED'); + } + }, }; module.exports = { diff --git a/server/lib/scene/scene.execute.js b/server/lib/scene/scene.execute.js index 34ec9a044f..91212cdefe 100644 --- a/server/lib/scene/scene.execute.js +++ b/server/lib/scene/scene.execute.js @@ -13,7 +13,13 @@ function execute(sceneSelector, scope) { if (!this.scenes[sceneSelector]) { throw new Error(`Scene with selector ${sceneSelector} not found.`); } - this.queue.push(() => executeActions(this, this.scenes[sceneSelector].actions, scope)); + this.queue.push(async () => { + try { + await executeActions(this, this.scenes[sceneSelector].actions, scope); + } catch (e) { + logger.error(e); + } + }); } catch (e) { logger.error(e); } diff --git a/server/lib/scene/scene.executeActions.js b/server/lib/scene/scene.executeActions.js index dfeae17df6..138e9bd503 100644 --- a/server/lib/scene/scene.executeActions.js +++ b/server/lib/scene/scene.executeActions.js @@ -1,6 +1,8 @@ const Promise = require('bluebird'); const { actionsFunc } = require('./scene.actions'); const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../utils/constants'); +const { AbortScene } = require('../../utils/coreErrors'); +const logger = require('../../utils/logger'); /** * @description Execute one action. @@ -25,8 +27,16 @@ async function executeAction(self, action, scope, columnIndex, rowIndex) { payload: { columnIndex, rowIndex }, }); } - // execute action - await actionsFunc[action.type](self, action, scope); + try { + // execute action + await actionsFunc[action.type](self, action, scope, columnIndex, rowIndex); + } catch (e) { + if (e instanceof AbortScene) { + throw e; + } else { + logger.warn(e); + } + } // send message to tell the UI the action has finished being executed if (columnIndex !== undefined && rowIndex !== undefined) { diff --git a/server/lib/scene/scene.executeSingleAction.js b/server/lib/scene/scene.executeSingleAction.js index a801f1f698..f9490c6db5 100644 --- a/server/lib/scene/scene.executeSingleAction.js +++ b/server/lib/scene/scene.executeSingleAction.js @@ -4,16 +4,17 @@ const { executeAction } = require('./scene.executeActions'); /** * @description Execute an action coming from an event. * @param {Object} action - The action to execute. + * @param {Object} scope - The scope of the action. * @example * scene.executeSingleAction({ * type: 'light.turn-on', * device: 'light', * }); */ -async function executeSingleAction(action) { +async function executeSingleAction(action, scope = {}) { logger.debug(`Executing action of type ${action.type}`); try { - await executeAction(this, action, action.scope); + await executeAction(this, action, scope); } catch (e) { logger.warn(`There was an error executing action ${action.type}`); logger.warn(e); diff --git a/server/models/scene.js b/server/models/scene.js index b52c9e702b..a0e5cae7f0 100644 --- a/server/models/scene.js +++ b/server/models/scene.js @@ -9,14 +9,21 @@ const actionSchema = Joi.array().items( type: Joi.string() .valid(ACTION_LIST) .required(), - deviceFeature: Joi.string(), - deviceFeatures: Joi.array().items(Joi.string()), + device_feature: Joi.string(), + device_features: Joi.array().items(Joi.string()), device: Joi.string(), devices: Joi.array().items(Joi.string()), user: Joi.string(), text: Joi.string(), value: Joi.number(), unit: Joi.string(), + conditions: Joi.array().items({ + variable: Joi.string().required(), + operator: Joi.string() + .valid(['=', '!=', '>', '>=', '<', '<=']) + .required(), + value: Joi.number(), + }), }), ), ); diff --git a/server/test/lib/scene/scene.executeActions.test.js b/server/test/lib/scene/scene.executeActions.test.js index 28d2067541..e1ec10436c 100644 --- a/server/test/lib/scene/scene.executeActions.test.js +++ b/server/test/lib/scene/scene.executeActions.test.js @@ -1,7 +1,9 @@ const { assert, fake } = require('sinon'); const chaiAssert = require('chai').assert; +const { expect } = require('chai'); const EventEmitter = require('events'); const { ACTIONS } = require('../../../utils/constants'); +const { AbortScene } = require('../../../utils/coreErrors'); const { executeActions } = require('../../../lib/scene/scene.executeActions'); const StateManager = require('../../../lib/state'); @@ -257,4 +259,102 @@ describe('scene.executeActions', () => { 1, ); }); + it('should execute action device.getValue', async () => { + const stateManager = new StateManager(event); + stateManager.setState('deviceFeature', 'my-device-feature', { + category: 'light', + type: 'binary', + last_value: 15, + }); + const device = { + setValue: fake.resolves(null), + }; + const scope = {}; + await executeActions( + { stateManager, event, device }, + [ + [ + { + type: ACTIONS.DEVICE.GET_VALUE, + device_feature: 'my-device-feature', + }, + ], + ], + scope, + ); + expect(scope).to.deep.equal({ '0.0.last_value': 15 }); + }); + it('should abort scene, condition is not verified', async () => { + const stateManager = new StateManager(event); + stateManager.setState('deviceFeature', 'my-device-feature', { + category: 'light', + type: 'binary', + last_value: 15, + }); + const device = { + setValue: fake.resolves(null), + }; + const scope = {}; + const promise = executeActions( + { stateManager, event, device }, + [ + [ + { + type: ACTIONS.DEVICE.GET_VALUE, + device_feature: 'my-device-feature', + }, + ], + [ + { + type: ACTIONS.CONDITION.ONLY_CONTINUE_IF, + conditions: [ + { + variable: '0.0.last_value', + operator: '=', + value: 20, + }, + ], + }, + ], + ], + scope, + ); + return chaiAssert.isRejected(promise, AbortScene); + }); + it('should finish scene, condition is verified', async () => { + const stateManager = new StateManager(event); + stateManager.setState('deviceFeature', 'my-device-feature', { + category: 'light', + type: 'binary', + last_value: 15, + }); + const device = { + setValue: fake.resolves(null), + }; + const scope = {}; + await executeActions( + { stateManager, event, device }, + [ + [ + { + type: ACTIONS.DEVICE.GET_VALUE, + device_feature: 'my-device-feature', + }, + ], + [ + { + type: ACTIONS.CONDITION.ONLY_CONTINUE_IF, + conditions: [ + { + variable: '0.0.last_value', + operator: '=', + value: 15, + }, + ], + }, + ], + ], + scope, + ); + }); }); diff --git a/server/test/lib/scene/scene.executeSingleAction.test.js b/server/test/lib/scene/scene.executeSingleAction.test.js index 2b7d898b0c..2c1a4367a9 100644 --- a/server/test/lib/scene/scene.executeSingleAction.test.js +++ b/server/test/lib/scene/scene.executeSingleAction.test.js @@ -1,22 +1,45 @@ const { assert, fake } = require('sinon'); const EventEmitter = require('events'); -const { ACTIONS } = require('../../../utils/constants'); +const { ACTIONS, DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../utils/constants'); const SceneManager = require('../../../lib/scene'); const StateManager = require('../../../lib/state'); const event = new EventEmitter(); +const deviceFeatureLightBinary = { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.BINARY, +}; + +const lightDevice = { + features: [deviceFeatureLightBinary], +}; + describe('scene.executeSingleAction', () => { it('should execute one action', async () => { const device = { setValue: fake.resolves(null), }; const stateManager = new StateManager(); + stateManager.setState('device', 'light-1', lightDevice); const sceneManager = new SceneManager(stateManager, event, device); await sceneManager.executeSingleAction({ type: ACTIONS.LIGHT.TURN_ON, devices: ['light-1'], }); - assert.calledOnce(device.setValue); + assert.calledWith(device.setValue, lightDevice, deviceFeatureLightBinary, 1); + }); + it('should execute one action', async () => { + const device = { + setValue: fake.resolves(null), + }; + const stateManager = new StateManager(); + stateManager.setState('device', 'light-1', lightDevice); + const sceneManager = new SceneManager(stateManager, event, device); + await sceneManager.executeSingleAction({ + type: ACTIONS.LIGHT.TURN_OFF, + devices: ['light-1'], + }); + assert.calledWith(device.setValue, lightDevice, deviceFeatureLightBinary, 0); }); }); diff --git a/server/utils/constants.js b/server/utils/constants.js index 8835cc8fe5..66f8e34e16 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -173,9 +173,11 @@ const CONDITIONS = { const ACTIONS = { DEVICE: { SET_VALUE: 'device.set-value', + GET_VALUE: 'device.get-value', }, LIGHT: { TURN_ON: 'light.turn-on', + TURN_OFF: 'light.turn-off', }, TIME: { DELAY: 'delay', @@ -190,6 +192,9 @@ const ACTIONS = { MESSAGE: { SEND: 'message.send', }, + CONDITION: { + ONLY_CONTINUE_IF: 'condition.only-continue-if', + }, }; const INTENTS = { diff --git a/server/utils/coreErrors.js b/server/utils/coreErrors.js index 1b59d9933f..30204d144d 100644 --- a/server/utils/coreErrors.js +++ b/server/utils/coreErrors.js @@ -40,6 +40,13 @@ class BadParameters extends Error { } } +class AbortScene extends Error { + constructor(message) { + super(); + this.message = message; + } +} + module.exports = { PasswordNotMatchingError, NotFoundError, @@ -47,4 +54,5 @@ module.exports = { BadParameters, NoValuesFoundError, PlatformNotCompatible, + AbortScene, };