From 26a0bf58216f0f9930240cfbb9752203a8e2ccdb Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 29 Sep 2023 15:31:16 +0200 Subject: [PATCH 01/44] Alarm Mode (backend+ test) + part of unlock UI --- front/src/components/app.jsx | 2 + front/src/components/header/index.jsx | 3 +- front/src/config/i18n/fr.json | 5 + front/src/routes/locked/index.js | 109 ++++++++++++++++++ front/src/routes/locked/style.css | 7 ++ server/lib/house/house.arm.js | 57 +++++++++ server/lib/house/house.disarm.js | 47 ++++++++ server/lib/house/house.disarmWithCode.js | 40 +++++++ server/lib/house/house.panic.js | 47 ++++++++ server/lib/house/house.partialArm.js | 48 ++++++++ server/lib/house/index.js | 10 ++ .../migrations/20230929085337-alarm-mode.js | 36 ++++++ server/models/house.js | 18 +++ server/test/lib/house/house.arm.test.js | 64 ++++++++++ server/test/lib/house/house.disarm.test.js | 56 +++++++++ .../lib/house/house.disarmWithCode.test.js | 61 ++++++++++ server/test/lib/house/house.panic.test.js | 49 ++++++++ .../test/lib/house/house.partialArm.test.js | 49 ++++++++ server/utils/constants.js | 24 ++++ server/utils/coreErrors.js | 16 +++ 20 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 front/src/routes/locked/index.js create mode 100644 front/src/routes/locked/style.css create mode 100644 server/lib/house/house.arm.js create mode 100644 server/lib/house/house.disarm.js create mode 100644 server/lib/house/house.disarmWithCode.js create mode 100644 server/lib/house/house.panic.js create mode 100644 server/lib/house/house.partialArm.js create mode 100644 server/migrations/20230929085337-alarm-mode.js create mode 100644 server/test/lib/house/house.arm.test.js create mode 100644 server/test/lib/house/house.disarm.test.js create mode 100644 server/test/lib/house/house.disarmWithCode.test.js create mode 100644 server/test/lib/house/house.panic.test.js create mode 100644 server/test/lib/house/house.partialArm.test.js diff --git a/front/src/components/app.jsx b/front/src/components/app.jsx index b698bcd0e7..4feb47605f 100644 --- a/front/src/components/app.jsx +++ b/front/src/components/app.jsx @@ -14,6 +14,7 @@ import Header from './header'; import Layout from './layout'; import Redirect from './router/Redirect'; import Login from '../routes/login'; +import Locked from '../routes/locked'; import Error from '../routes/error'; import ForgotPassword from '../routes/forgot-password'; import ResetPassword from '../routes/reset-password'; @@ -178,6 +179,7 @@ const AppRouter = connect( ) : ( )} + {config.gatewayMode ? : } {config.gatewayMode ? : } {config.gatewayMode ? ( diff --git a/front/src/components/header/index.jsx b/front/src/components/header/index.jsx index 8a2cee7e7d..7203c8aefc 100644 --- a/front/src/components/header/index.jsx +++ b/front/src/components/header/index.jsx @@ -21,7 +21,8 @@ const PAGES_WITHOUT_HEADER = [ '/gateway-configure-two-factor', '/confirm-email', '/dashboard/integration/device/google-home/authorize', - '/dashboard/integration/device/alexa/authorize' + '/dashboard/integration/device/alexa/authorize', + '/locked' ]; const Header = ({ ...props }) => { diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 16052f2189..3b12fe9322 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -67,6 +67,11 @@ "invalidEmail": "E-mail invalide", "needHelpText": "Besoin d'aide ? Rejoignez-nous sur Gladys Community." }, + "locked": { + "cardTitle": "Maison verrouillée", + "description": "Merci de taper votre code afin de déverrouiller l'alarme.", + "codePlaceholder": "Tapez votre code" + }, "signup": { "welcome": { "title": "Bienvenue dans Gladys !", diff --git a/front/src/routes/locked/index.js b/front/src/routes/locked/index.js new file mode 100644 index 0000000000..ea771d3139 --- /dev/null +++ b/front/src/routes/locked/index.js @@ -0,0 +1,109 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import { Text, Localizer } from 'preact-i18n'; +import cx from 'classnames'; +import style from './style.css'; + +const BUTTON_ARRAY = [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9] +]; + +const KeyPadComponent = ({ currentCode, typeLetter, clearPreviousLetter }) => ( +
+
+ + } + /> + +
+ +
+
+ {BUTTON_ARRAY.map(row => ( +
+ {row.map(cell => ( +
+ +
+ ))} +
+ ))} +
+); + +class Locked extends Component { + clearPreviousLetter = e => { + e.preventDefault(); + if (this.state.currentCode.length > 0) { + this.setState(prevState => { + return { ...prevState, currentCode: prevState.currentCode.slice(0, -1) }; + }); + } + }; + typeLetter = (e, letter) => { + e.preventDefault(); + this.setState(prevState => { + return { ...prevState, currentCode: prevState.currentCode + letter }; + }); + }; + constructor(props) { + super(props); + this.props = props; + this.state = { + currentCode: '' + }; + } + componentDidMount() {} + render({}, { currentCode }) { + return ( +
+
+ +
+
+ ); + } +} + +export default connect('', {})(Locked); diff --git a/front/src/routes/locked/style.css b/front/src/routes/locked/style.css new file mode 100644 index 0000000000..2469753448 --- /dev/null +++ b/front/src/routes/locked/style.css @@ -0,0 +1,7 @@ +.lockedContainer { + margin-top: 5rem; +} + +.lockedContainer .cardTitle { + font-weight: 700; +} diff --git a/server/lib/house/house.arm.js b/server/lib/house/house.arm.js new file mode 100644 index 0000000000..cf768794e1 --- /dev/null +++ b/server/lib/house/house.arm.js @@ -0,0 +1,57 @@ +const Promise = require('bluebird'); +const db = require('../../models'); +const { ALARM_MODES, EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../utils/constants'); +const { NotFoundError, ConflictError } = require('../../utils/coreErrors'); + +/** + * @public + * @description Arm house Alarm. + * @param {object} selector - Selector of the house. + * @returns {Promise} Resolve with house object. + * @example + * const mainHouse = await gladys.house.arm('main-house'); + */ +async function arm(selector) { + const house = await db.House.findOne({ + where: { + selector, + }, + }); + + if (house === null) { + throw new NotFoundError('House not found'); + } + + if (house.alarm_mode === ALARM_MODES.ARMED) { + throw new ConflictError('House is already armed'); + } + + // Emit websocket event to tell UI an alarm is arming + this.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.ALARM.ARMING, + payload: { + house: selector, + }, + }); + // Wait the delay before arming + await Promise.delay(house.alarm_delay_before_arming * 1000); + // Update database + await house.update({ alarm_mode: ALARM_MODES.ARMED }); + // Check scene triggers + this.event.emit(EVENTS.TRIGGERS.CHECK, { + type: EVENTS.ALARM.ARM, + house: selector, + }); + // Emit websocket event to update UI + this.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.ALARM.ARMED, + payload: { + house: selector, + }, + }); + return house.get({ plain: true }); +} + +module.exports = { + arm, +}; diff --git a/server/lib/house/house.disarm.js b/server/lib/house/house.disarm.js new file mode 100644 index 0000000000..d85af2ccff --- /dev/null +++ b/server/lib/house/house.disarm.js @@ -0,0 +1,47 @@ +const Promise = require('bluebird'); +const db = require('../../models'); +const { ALARM_MODES, EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../utils/constants'); +const { NotFoundError, ConflictError } = require('../../utils/coreErrors'); + +/** + * @public + * @description Disarm house Alarm. + * @param {object} selector - Selector of the house. + * @returns {Promise} Resolve with house object. + * @example + * const mainHouse = await gladys.house.disarm('main-house'); + */ +async function disarm(selector) { + const house = await db.House.findOne({ + where: { + selector, + }, + }); + + if (house === null) { + throw new NotFoundError('House not found'); + } + + if (house.alarm_mode === ALARM_MODES.DISARMED) { + throw new ConflictError('House is already disarmed'); + } + // Update database + await house.update({ alarm_mode: ALARM_MODES.DISARMED }); + // Check scene triggers + this.event.emit(EVENTS.TRIGGERS.CHECK, { + type: EVENTS.ALARM.DISARM, + house: selector, + }); + // Emit websocket event to update UI + this.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.ALARM.DISARMED, + payload: { + house: selector, + }, + }); + return house.get({ plain: true }); +} + +module.exports = { + disarm, +}; diff --git a/server/lib/house/house.disarmWithCode.js b/server/lib/house/house.disarmWithCode.js new file mode 100644 index 0000000000..7ae55ff4e1 --- /dev/null +++ b/server/lib/house/house.disarmWithCode.js @@ -0,0 +1,40 @@ +const Promise = require('bluebird'); +const db = require('../../models'); +const { ALARM_MODES } = require('../../utils/constants'); +const { NotFoundError, ConflictError, ForbiddenError } = require('../../utils/coreErrors'); + +/** + * @public + * @description Disarm alarm with code. + * @param {string} selector - Selector of the house. + * @param {string} code - Code of the house. + * @returns {Promise} Resolve when unlocked. + * @example + * await gladys.house.disarmWithCode('main-house', '123456'); + */ +async function disarmWithCode(selector, code) { + const house = await db.House.findOne({ + where: { + selector, + }, + }); + + if (house === null) { + throw new NotFoundError('House not found'); + } + + if (house.alarm_mode === ALARM_MODES.DISARMED) { + throw new ConflictError('House is already disarmed'); + } + + if (house.alarm_code !== code) { + throw new ForbiddenError('Invalid code'); + } + + // Disarm house + await this.disarm(selector); +} + +module.exports = { + disarmWithCode, +}; diff --git a/server/lib/house/house.panic.js b/server/lib/house/house.panic.js new file mode 100644 index 0000000000..3f5585551f --- /dev/null +++ b/server/lib/house/house.panic.js @@ -0,0 +1,47 @@ +const Promise = require('bluebird'); +const db = require('../../models'); +const { ALARM_MODES, EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../utils/constants'); +const { NotFoundError, ConflictError } = require('../../utils/coreErrors'); + +/** + * @public + * @description Make house alarm in panic mode. + * @param {object} selector - Selector of the house. + * @returns {Promise} Resolve with house object. + * @example + * const mainHouse = await gladys.house.arm('main-house'); + */ +async function panic(selector) { + const house = await db.House.findOne({ + where: { + selector, + }, + }); + + if (house === null) { + throw new NotFoundError('House not found'); + } + + if (house.alarm_mode === ALARM_MODES.PANIC) { + throw new ConflictError('House is already in panic mode'); + } + // Update database + await house.update({ alarm_mode: ALARM_MODES.PANIC }); + // Check scene triggers + this.event.emit(EVENTS.TRIGGERS.CHECK, { + type: EVENTS.ALARM.PANIC, + house: selector, + }); + // Emit websocket event to update UI + this.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.ALARM.PANIC, + payload: { + house: selector, + }, + }); + return house.get({ plain: true }); +} + +module.exports = { + panic, +}; diff --git a/server/lib/house/house.partialArm.js b/server/lib/house/house.partialArm.js new file mode 100644 index 0000000000..a8086ba2e0 --- /dev/null +++ b/server/lib/house/house.partialArm.js @@ -0,0 +1,48 @@ +const Promise = require('bluebird'); +const db = require('../../models'); +const { ALARM_MODES, EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../utils/constants'); +const { NotFoundError, ConflictError } = require('../../utils/coreErrors'); + +/** + * @public + * @description Partial arm house Alarm. + * @param {object} selector - Selector of the house. + * @returns {Promise} Resolve with house object. + * @example + * const mainHouse = await gladys.house.arm('main-house'); + */ +async function partialArm(selector) { + const house = await db.House.findOne({ + where: { + selector, + }, + }); + + if (house === null) { + throw new NotFoundError('House not found'); + } + + if (house.alarm_mode === ALARM_MODES.PARTIALLY_ARMED) { + throw new ConflictError('House is already partially armed'); + } + + // Update database + await house.update({ alarm_mode: ALARM_MODES.PARTIALLY_ARMED }); + // Check scene triggers + this.event.emit(EVENTS.TRIGGERS.CHECK, { + type: EVENTS.ALARM.PARTIAL_ARM, + house: selector, + }); + // Emit websocket event to update UI + this.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.ALARM.PARTIALLY_ARMED, + payload: { + house: selector, + }, + }); + return house.get({ plain: true }); +} + +module.exports = { + partialArm, +}; diff --git a/server/lib/house/index.js b/server/lib/house/index.js index ee65130dea..8b9e2a7cfc 100644 --- a/server/lib/house/index.js +++ b/server/lib/house/index.js @@ -1,9 +1,14 @@ +const { arm } = require('./house.arm'); const { create } = require('./house.create'); const { destroy } = require('./house.destroy'); +const { disarm } = require('./house.disarm'); +const { disarmWithCode } = require('./house.disarmWithCode'); const { get } = require('./house.get'); const { getRooms } = require('./house.getRooms'); const { getUsersAtHome } = require('./house.getUsersAtHome'); const { isEmpty } = require('./house.isEmpty'); +const { panic } = require('./house.panic'); +const { partialArm } = require('./house.partialArm'); const { update } = require('./house.update'); const { userLeft } = require('./house.userLeft'); const { userSeen } = require('./house.userSeen'); @@ -14,11 +19,16 @@ const House = function House(event, stateManager) { this.stateManager = stateManager; }; +House.prototype.arm = arm; House.prototype.create = create; House.prototype.destroy = destroy; +House.prototype.disarm = disarm; +House.prototype.disarmWithCode = disarmWithCode; House.prototype.get = get; House.prototype.getRooms = getRooms; House.prototype.getUsersAtHome = getUsersAtHome; +House.prototype.panic = panic; +House.prototype.partialArm = partialArm; House.prototype.isEmpty = isEmpty; House.prototype.update = update; House.prototype.userLeft = userLeft; diff --git a/server/migrations/20230929085337-alarm-mode.js b/server/migrations/20230929085337-alarm-mode.js new file mode 100644 index 0000000000..4d3675ea55 --- /dev/null +++ b/server/migrations/20230929085337-alarm-mode.js @@ -0,0 +1,36 @@ +const { ALARM_MODES } = require('../utils/constants'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('t_house', 'alarm_mode', { + type: Sequelize.STRING, + allowNull: false, + defaultValue: ALARM_MODES.DISARMED, + }); + await queryInterface.addColumn('t_house', 'alarm_code', { + type: Sequelize.STRING, + allowNull: true, + defaultValue: null, + }); + await queryInterface.addColumn('t_house', 'alarm_delay_before_arming', { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 10, + }); + await queryInterface.addColumn('t_session', 'tablet_mode', { + allowNull: false, + type: Sequelize.BOOLEAN, + defaultValue: false, + }); + await queryInterface.addColumn('t_session', 'current_house_id', { + allowNull: true, + type: Sequelize.UUID, + references: { + model: 't_house', + key: 'id', + }, + }); + }, + + down: async () => {}, +}; diff --git a/server/models/house.js b/server/models/house.js index 86cb986668..bf0bcc0df0 100644 --- a/server/models/house.js +++ b/server/models/house.js @@ -1,4 +1,5 @@ const { addSelector } = require('../utils/addSelector'); +const { ALARM_MODES_LIST } = require('../utils/constants'); module.exports = (sequelize, DataTypes) => { const house = sequelize.define( @@ -30,6 +31,23 @@ module.exports = (sequelize, DataTypes) => { allowNull: true, type: DataTypes.DOUBLE, }, + alarm_mode: { + allowNull: false, + type: DataTypes.ENUM(ALARM_MODES_LIST), + }, + alarm_code: { + allowNull: true, + type: DataTypes.STRING, + validate: { + len: [4, 8], + isNumeric: true, + }, + }, + alarm_delay_before_arming: { + allowNull: false, + type: DataTypes.INTEGER, + defaultValue: 10, + }, }, {}, ); diff --git a/server/test/lib/house/house.arm.test.js b/server/test/lib/house/house.arm.test.js new file mode 100644 index 0000000000..a60c3ff7d0 --- /dev/null +++ b/server/test/lib/house/house.arm.test.js @@ -0,0 +1,64 @@ +const { expect } = require('chai'); +const assertChai = require('chai').assert; +const sinon = require('sinon'); + +const { fake, assert } = sinon; + +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../utils/constants'); + +const House = require('../../../lib/house'); + +const event = { + emit: fake.returns(null), +}; + +describe('house.arm', () => { + const house = new House(event); + beforeEach(async () => { + await house.update('test-house', { + alarm_delay_before_arming: 0, + }); + sinon.reset(); + }); + afterEach(() => { + sinon.reset(); + }); + it('should arm a house', async () => { + await house.arm('test-house'); + assert.calledThrice(event.emit); + expect(event.emit.firstCall.args).to.deep.equal([ + EVENTS.WEBSOCKET.SEND_ALL, + { + type: WEBSOCKET_MESSAGE_TYPES.ALARM.ARMING, + payload: { + house: 'test-house', + }, + }, + ]); + expect(event.emit.secondCall.args).to.deep.equal([ + EVENTS.TRIGGERS.CHECK, + { + type: EVENTS.ALARM.ARM, + house: 'test-house', + }, + ]); + expect(event.emit.thirdCall.args).to.deep.equal([ + EVENTS.WEBSOCKET.SEND_ALL, + { + type: WEBSOCKET_MESSAGE_TYPES.ALARM.ARMED, + payload: { + house: 'test-house', + }, + }, + ]); + }); + it('should return house not found', async () => { + const promise = house.arm('house-not-found'); + return assertChai.isRejected(promise, 'House not found'); + }); + it('should return house is already armed error', async () => { + await house.arm('test-house'); + const promise = house.arm('test-house'); + return assertChai.isRejected(promise, 'House is already armed'); + }); +}); diff --git a/server/test/lib/house/house.disarm.test.js b/server/test/lib/house/house.disarm.test.js new file mode 100644 index 0000000000..269ee6fbbd --- /dev/null +++ b/server/test/lib/house/house.disarm.test.js @@ -0,0 +1,56 @@ +const { expect } = require('chai'); +const assertChai = require('chai').assert; +const sinon = require('sinon'); + +const { fake, assert } = sinon; + +const { EVENTS, WEBSOCKET_MESSAGE_TYPES, ALARM_MODES } = require('../../../utils/constants'); + +const House = require('../../../lib/house'); + +const event = { + emit: fake.returns(null), +}; + +describe('house.disarm', () => { + const house = new House(event); + beforeEach(async () => { + await house.update('test-house', { + alarm_delay_before_arming: 0, + alarm_mode: ALARM_MODES.ARMED, + }); + sinon.reset(); + }); + afterEach(() => { + sinon.reset(); + }); + it('should disarm a house', async () => { + await house.disarm('test-house'); + assert.calledTwice(event.emit); + expect(event.emit.firstCall.args).to.deep.equal([ + EVENTS.TRIGGERS.CHECK, + { + type: EVENTS.ALARM.DISARM, + house: 'test-house', + }, + ]); + expect(event.emit.secondCall.args).to.deep.equal([ + EVENTS.WEBSOCKET.SEND_ALL, + { + type: WEBSOCKET_MESSAGE_TYPES.ALARM.DISARMED, + payload: { + house: 'test-house', + }, + }, + ]); + }); + it('should return house not found', async () => { + const promise = house.disarm('house-not-found'); + return assertChai.isRejected(promise, 'House not found'); + }); + it('should return house is already disarmed error', async () => { + await house.disarm('test-house'); + const promise = house.disarm('test-house'); + return assertChai.isRejected(promise, 'House is already disarmed'); + }); +}); diff --git a/server/test/lib/house/house.disarmWithCode.test.js b/server/test/lib/house/house.disarmWithCode.test.js new file mode 100644 index 0000000000..3f7a054058 --- /dev/null +++ b/server/test/lib/house/house.disarmWithCode.test.js @@ -0,0 +1,61 @@ +const { expect } = require('chai'); +const assertChai = require('chai').assert; +const sinon = require('sinon'); + +const { fake, assert } = sinon; + +const { EVENTS, WEBSOCKET_MESSAGE_TYPES, ALARM_MODES } = require('../../../utils/constants'); + +const House = require('../../../lib/house'); + +const event = { + emit: fake.returns(null), +}; + +describe('house.disarmWithCode', () => { + const house = new House(event); + beforeEach(async () => { + await house.update('test-house', { + alarm_code: '123456', + alarm_delay_before_arming: 0, + alarm_mode: ALARM_MODES.ARMED, + }); + sinon.reset(); + }); + afterEach(() => { + sinon.reset(); + }); + it('should disarm a house with code', async () => { + await house.disarmWithCode('test-house', '123456'); + assert.calledTwice(event.emit); + expect(event.emit.firstCall.args).to.deep.equal([ + EVENTS.TRIGGERS.CHECK, + { + type: EVENTS.ALARM.DISARM, + house: 'test-house', + }, + ]); + expect(event.emit.secondCall.args).to.deep.equal([ + EVENTS.WEBSOCKET.SEND_ALL, + { + type: WEBSOCKET_MESSAGE_TYPES.ALARM.DISARMED, + payload: { + house: 'test-house', + }, + }, + ]); + }); + it('should return house not found', async () => { + const promise = house.disarmWithCode('house-not-found', '123456'); + return assertChai.isRejected(promise, 'House not found'); + }); + it('should return wrong code', async () => { + const promise = house.disarmWithCode('test-house', '12'); + return assertChai.isRejected(promise, 'Invalid code'); + }); + it('should return house is already disarmed error', async () => { + await house.disarmWithCode('test-house', '123456'); + const promise = house.disarmWithCode('test-house', '123456'); + return assertChai.isRejected(promise, 'House is already disarmed'); + }); +}); diff --git a/server/test/lib/house/house.panic.test.js b/server/test/lib/house/house.panic.test.js new file mode 100644 index 0000000000..1c64c8a28f --- /dev/null +++ b/server/test/lib/house/house.panic.test.js @@ -0,0 +1,49 @@ +const { expect } = require('chai'); +const assertChai = require('chai').assert; +const sinon = require('sinon'); + +const { fake, assert } = sinon; + +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../utils/constants'); + +const House = require('../../../lib/house'); + +const event = { + emit: fake.returns(null), +}; + +describe('house.panic', () => { + const house = new House(event); + afterEach(() => { + sinon.reset(); + }); + it('should set house in panic mode', async () => { + await house.panic('test-house'); + assert.calledTwice(event.emit); + expect(event.emit.firstCall.args).to.deep.equal([ + EVENTS.TRIGGERS.CHECK, + { + type: EVENTS.ALARM.PANIC, + house: 'test-house', + }, + ]); + expect(event.emit.secondCall.args).to.deep.equal([ + EVENTS.WEBSOCKET.SEND_ALL, + { + type: WEBSOCKET_MESSAGE_TYPES.ALARM.PANIC, + payload: { + house: 'test-house', + }, + }, + ]); + }); + it('should return house not found', async () => { + const promise = house.panic('house-not-found'); + return assertChai.isRejected(promise, 'House not found'); + }); + it('should return house is already in panic mode error', async () => { + await house.panic('test-house'); + const promise = house.panic('test-house'); + return assertChai.isRejected(promise, 'House is already in panic mode'); + }); +}); diff --git a/server/test/lib/house/house.partialArm.test.js b/server/test/lib/house/house.partialArm.test.js new file mode 100644 index 0000000000..c12f396e83 --- /dev/null +++ b/server/test/lib/house/house.partialArm.test.js @@ -0,0 +1,49 @@ +const { expect } = require('chai'); +const assertChai = require('chai').assert; +const sinon = require('sinon'); + +const { fake, assert } = sinon; + +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../utils/constants'); + +const House = require('../../../lib/house'); + +const event = { + emit: fake.returns(null), +}; + +describe('house.partialArm', () => { + const house = new House(event); + afterEach(() => { + sinon.reset(); + }); + it('should set house in partial arm mode', async () => { + await house.partialArm('test-house'); + assert.calledTwice(event.emit); + expect(event.emit.firstCall.args).to.deep.equal([ + EVENTS.TRIGGERS.CHECK, + { + type: EVENTS.ALARM.PARTIAL_ARM, + house: 'test-house', + }, + ]); + expect(event.emit.secondCall.args).to.deep.equal([ + EVENTS.WEBSOCKET.SEND_ALL, + { + type: WEBSOCKET_MESSAGE_TYPES.ALARM.PARTIALLY_ARMED, + payload: { + house: 'test-house', + }, + }, + ]); + }); + it('should return house not found', async () => { + const promise = house.partialArm('house-not-found'); + return assertChai.isRejected(promise, 'House not found'); + }); + it('should return house is already partially armed mode error', async () => { + await house.partialArm('test-house'); + const promise = house.partialArm('test-house'); + return assertChai.isRejected(promise, 'House is already partially armed'); + }); +}); diff --git a/server/utils/constants.js b/server/utils/constants.js index 0775f33bc3..0b318a29e7 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -101,6 +101,12 @@ const SYSTEM_VARIABLE_NAMES = { }; const EVENTS = { + ALARM: { + ARM: 'alarm.arm', + DISARM: 'alarm.disarm', + PARTIAL_ARM: 'alarm.partial-arm', + PANIC: 'alarm.panic', + }, CALENDAR: { EVENT_IS_COMING: 'calendar.event-is-coming', CHECK_IF_EVENT_IS_COMING: 'calendar.check-if-event-is-coming', @@ -808,6 +814,13 @@ const DEVICE_ROTATION = { }; const WEBSOCKET_MESSAGE_TYPES = { + ALARM: { + ARMING: 'alarm.arming', + ARMED: 'alarm.armed', + DISARMED: 'alarm.disarmed', + PARTIALLY_ARMED: 'alarm.partial-arm', + PANIC: 'alarm.panic', + }, BACKUP: { DOWNLOADED: 'backup.downloaded', }, @@ -960,6 +973,13 @@ const DEFAULT_VALUE_TEMPERATURE = { MAXIMUM: 24, }; +const ALARM_MODES = { + DISARMED: 'disarmed', + ARMED: 'armed', + PARTIALLY_ARMED: 'partially-armed', + PANIC: 'panic', +}; + const createList = (obj) => { const list = []; Object.keys(obj).forEach((key) => { @@ -991,6 +1011,7 @@ const DEVICE_FEATURE_STATE_AGGREGATE_TYPES_LIST = createList(DEVICE_FEATURE_STAT const JOB_TYPES_LIST = createList(JOB_TYPES); const JOB_STATUS_LIST = createList(JOB_STATUS); const JOB_ERROR_TYPES_LIST = createList(JOB_ERROR_TYPES); +const ALARM_MODES_LIST = createList(ALARM_MODES); module.exports.STATE = STATE; module.exports.BUTTON_STATUS = BUTTON_STATUS; @@ -1058,3 +1079,6 @@ module.exports.JOB_ERROR_TYPES_LIST = JOB_ERROR_TYPES_LIST; module.exports.DEFAULT_VALUE_HUMIDITY = DEFAULT_VALUE_HUMIDITY; module.exports.DEFAULT_VALUE_TEMPERATURE = DEFAULT_VALUE_TEMPERATURE; + +module.exports.ALARM_MODES = ALARM_MODES; +module.exports.ALARM_MODES_LIST = ALARM_MODES_LIST; diff --git a/server/utils/coreErrors.js b/server/utils/coreErrors.js index 30204d144d..024052cfcf 100644 --- a/server/utils/coreErrors.js +++ b/server/utils/coreErrors.js @@ -40,6 +40,20 @@ class BadParameters extends Error { } } +class ConflictError extends Error { + constructor(message) { + super(); + this.message = message; + } +} + +class ForbiddenError extends Error { + constructor(message) { + super(); + this.message = message; + } +} + class AbortScene extends Error { constructor(message) { super(); @@ -55,4 +69,6 @@ module.exports = { NoValuesFoundError, PlatformNotCompatible, AbortScene, + ConflictError, + ForbiddenError, }; From 10227e2f019d7696aa7d8c0fa2b4d31f8eb55d50 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 9 Oct 2023 13:19:59 +0200 Subject: [PATCH 02/44] Add form to update house code/delay --- front/src/actions/house.js | 25 ++++++++ front/src/components/house/EditHouse.jsx | 64 +++++++++++++++++++ .../components/house/EditHouseComponent.jsx | 8 +++ front/src/config/i18n/fr.json | 14 +++- 4 files changed, 110 insertions(+), 1 deletion(-) diff --git a/front/src/actions/house.js b/front/src/actions/house.js index f1c1664b2f..e6eb74928e 100644 --- a/front/src/actions/house.js +++ b/front/src/actions/house.js @@ -64,6 +64,31 @@ function createActions(store) { }); store.setState(newState); }, + updateHouseAlarmCode(state, code, houseIndex) { + const newState = update(state, { + houses: { + [houseIndex]: { + alarm_code: { + $set: code + } + } + } + }); + store.setState(newState); + }, + updateHouseDelayBeforeArming(state, delayBeforeArming, houseIndex) { + console.log(delayBeforeArming); + const newState = update(state, { + houses: { + [houseIndex]: { + alarm_delay_before_arming: { + $set: parseInt(delayBeforeArming, 10) + } + } + } + }); + store.setState(newState); + }, updateHouseLocation(state, latitude, longitude, houseIndex) { const newState = update(state, { houses: { diff --git a/front/src/components/house/EditHouse.jsx b/front/src/components/house/EditHouse.jsx index dab23bf28d..d6ded09d26 100644 --- a/front/src/components/house/EditHouse.jsx +++ b/front/src/components/house/EditHouse.jsx @@ -93,6 +93,70 @@ const EditHouse = ({ children, ...props }) => ( +
+

+ +

+

+ +

+
+
+ +
+ + } + value={props.house.alarm_code} + className="form-control" + onInput={props.updateHouseAlarmCode} + /> + + + + +
+
+ +
+
+
+ + +
+ +
+
+
+
+ +
+ +
+
+ +
+
+ +
+
+ + + ); + } +} + +export default connect('httpClient,session', {})(AlarmComponent); diff --git a/front/src/components/boxs/alarm/EditAlarm.jsx b/front/src/components/boxs/alarm/EditAlarm.jsx new file mode 100644 index 0000000000..6e5498b5e6 --- /dev/null +++ b/front/src/components/boxs/alarm/EditAlarm.jsx @@ -0,0 +1,82 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import { Text, Localizer } from 'preact-i18n'; +import BaseEditBox from '../baseEditBox'; +import actions from '../../../actions/dashboard/boxActions'; + +class EditAlarm extends Component { + updateBoxHouse = e => { + this.props.updateBoxConfig(this.props.x, this.props.y, { + house: e.target.value + }); + }; + + updateBoxName = e => { + this.props.updateBoxConfig(this.props.x, this.props.y, { + name: e.target.value + }); + }; + + getHouses = async () => { + try { + await this.setState({ + error: false, + pending: true + }); + const houses = await this.props.httpClient.get('/api/v1/house'); + this.setState({ + houses, + pending: false + }); + } catch (e) { + console.error(e); + this.setState({ + error: true, + pending: false + }); + } + }; + + componentDidMount() { + this.getHouses(); + } + + render(props, { houses }) { + return ( + +
+
+ + + } + /> + +
+ + +
+
+ ); + } +} + +export default connect('httpClient', actions)(EditAlarm); diff --git a/front/src/components/boxs/alarm/style.css b/front/src/components/boxs/alarm/style.css new file mode 100644 index 0000000000..bce7192a01 --- /dev/null +++ b/front/src/components/boxs/alarm/style.css @@ -0,0 +1,4 @@ +.alarmActionButton { + height: 5rem; + line-height: 16px; +} diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index f677766589..79f23bc34c 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -206,6 +206,7 @@ "selectBoxType": "Select a type", "selectBoxTypeLabel": "Choose which widget to display here", "boxTitle": { + "alarm": "Alarm", "weather": "Weather", "temperature-in-room": "Temperature in room", "humidity-in-room": "Humidity in room", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 681c474d54..1a641b1835 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -223,6 +223,7 @@ "selectBoxType": "Sélectionner un type", "selectBoxTypeLabel": "Choisissez quel widget afficher ici", "boxTitle": { + "alarm": "Alarme", "weather": "Météo", "temperature-in-room": "Température de la pièce", "humidity-in-room": "Humidité de la pièce", @@ -355,6 +356,16 @@ "editNameLabel": "Entrez le nom de cette box", "editNamePlaceholder": "Nom affiché sur le tableau de bord", "editSceneLabel": "Sélectionnez la scène que vous souhaitez afficher ici." + }, + "alarm": { + "armButton": "Armer", + "disarmButton": "Désarmer", + "partiallyArmedButton": "Armement", + "partiallyArmedButtonSecondLine": "partiel", + "panicButton": "Panique", + "editBoxNameLabel": "Nom du widget", + "editBoxNamePlaceholder": "Entrez le nom du widget", + "editHouseLabel": "Sélectionnez la maison qui est concernée pour l'activation/désactivation de l'alarme." } } }, diff --git a/front/src/routes/dashboard/Box.jsx b/front/src/routes/dashboard/Box.jsx index 3cf7ba7550..3ba0122319 100644 --- a/front/src/routes/dashboard/Box.jsx +++ b/front/src/routes/dashboard/Box.jsx @@ -9,6 +9,7 @@ import ChartBox from '../../components/boxs/chart/Chart'; import EcowattBox from '../../components/boxs/ecowatt/Ecowatt'; import ClockBox from '../../components/boxs/clock/Clock'; import SceneBox from '../../components/boxs/scene/SceneBox'; +import AlarmBox from '../../components/boxs/alarm/Alarm'; const Box = ({ children, ...props }) => { switch (props.box.type) { @@ -34,6 +35,8 @@ const Box = ({ children, ...props }) => { return ; case 'scene': return ; + case 'alarm': + return ; } }; diff --git a/front/src/routes/dashboard/edit-dashboard/EditBox.jsx b/front/src/routes/dashboard/edit-dashboard/EditBox.jsx index b2a043c602..8c600f5aa7 100644 --- a/front/src/routes/dashboard/edit-dashboard/EditBox.jsx +++ b/front/src/routes/dashboard/edit-dashboard/EditBox.jsx @@ -11,6 +11,7 @@ import EditClock from '../../../components/boxs/clock/EditClock'; import SelectBoxType from '../../../components/boxs/SelectBoxType'; import EditSceneBox from '../../../components/boxs/scene/EditSceneBox'; +import EditAlarmBox from '../../../components/boxs/alarm/EditAlarm'; const Box = ({ children, ...props }) => { switch (props.box.type) { @@ -36,6 +37,8 @@ const Box = ({ children, ...props }) => { return ; case 'scene': return ; + case 'alarm': + return ; default: return ; } From 454f06834989bb0e7233cd8c6cc39951e0083530 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 20 Oct 2023 10:31:05 +0200 Subject: [PATCH 05/44] Add a route to get house by selector --- server/api/controllers/house.controller.js | 20 ++++++++++++++++++++ server/api/routes.js | 4 ++++ server/test/controllers/house/house.test.js | 13 +++++++++++++ 3 files changed, 37 insertions(+) diff --git a/server/api/controllers/house.controller.js b/server/api/controllers/house.controller.js index 0ebbc4549c..625f667206 100644 --- a/server/api/controllers/house.controller.js +++ b/server/api/controllers/house.controller.js @@ -46,6 +46,25 @@ module.exports = function HouseController(gladys) { res.json(houses); } + /** + * @api {get} /api/v1/house/:house_selector getBySelector + * @apiName getBySelector + * @apiGroup House + * @apiUse HouseParam + * @apiSuccessExample {json} Success-Example + * { + * "id": "7932e6b3-b944-49a9-8d63-b98b8ecb2509", + * "name": "My house", + * "selector": "my-house" + * "updated_at": "2019-05-09T03:43:54.247Z", + * "created_at": "2019-05-09T03:43:54.247Z" + * } + */ + async function getBySelector(req, res) { + const house = await gladys.house.getBySelector(req.params.house_selector); + res.json(house); + } + /** * @api {patch} /api/v1/house/:house_selector update * @apiName update @@ -152,6 +171,7 @@ module.exports = function HouseController(gladys) { create: asyncMiddleware(create), destroy: asyncMiddleware(destroy), get: asyncMiddleware(get), + getBySelector: asyncMiddleware(getBySelector), update: asyncMiddleware(update), userSeen: asyncMiddleware(userSeen), getRooms: asyncMiddleware(getRooms), diff --git a/server/api/routes.js b/server/api/routes.js index e3b283eb48..dc62ec1749 100644 --- a/server/api/routes.js +++ b/server/api/routes.js @@ -229,6 +229,10 @@ function getRoutes(gladys) { admin: true, controller: houseController.create, }, + 'get /api/v1/house/:house_selector': { + authenticated: true, + controller: houseController.getBySelector, + }, 'patch /api/v1/house/:house_selector': { authenticated: true, admin: true, diff --git a/server/test/controllers/house/house.test.js b/server/test/controllers/house/house.test.js index 5b47fc608c..aa412376a8 100644 --- a/server/test/controllers/house/house.test.js +++ b/server/test/controllers/house/house.test.js @@ -116,6 +116,19 @@ describe('PATCH /api/v1/house/test-house', () => { }); }); +describe('GET /api/v1/house/test-house', () => { + it('should get a house by selector', async () => { + await authenticatedRequest + .get('/api/v1/house/test-house') + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + expect(res.body).to.have.property('selector', 'test-house'); + expect(res.body).to.have.property('alarm_mode', 'disarmed'); + }); + }); +}); + describe('DELETE /api/v1/house/test-house', () => { it('should delete a house', async () => { await authenticatedRequest From bdc63e5628d40301c7ae533b1f6ec5be754f61c2 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 20 Oct 2023 10:37:28 +0200 Subject: [PATCH 06/44] Add arming status widget --- front/src/components/boxs/alarm/Alarm.jsx | 139 +++++++++++++++++----- front/src/config/i18n/fr.json | 10 +- 2 files changed, 121 insertions(+), 28 deletions(-) diff --git a/front/src/components/boxs/alarm/Alarm.jsx b/front/src/components/boxs/alarm/Alarm.jsx index b994899abf..e95bc48dd0 100644 --- a/front/src/components/boxs/alarm/Alarm.jsx +++ b/front/src/components/boxs/alarm/Alarm.jsx @@ -2,12 +2,28 @@ import { Component } from 'preact'; import { connect } from 'unistore/preact'; import cx from 'classnames'; import { Text } from 'preact-i18n'; +import { ALARM_MODES, WEBSOCKET_MESSAGE_TYPES } from '../../../../../server/utils/constants'; import style from './style.css'; class AlarmComponent extends Component { state = {}; + arming = async () => { + await this.setState({ arming: true }); + }; + + getHouse = async () => { + await this.setState({ loading: true }); + try { + const house = await this.props.httpClient.get(`/api/v1/house/${this.props.box.house}`); + await this.setState({ house, arming: false }); + } catch (e) { + console.error(e); + } + await this.setState({ loading: false }); + }; + callAlarmApi = async action => { await this.setState({ loading: true }); try { @@ -31,40 +47,109 @@ class AlarmComponent extends Component { await this.callAlarmApi('panic'); }; - render(props, {}) { + componentDidMount() { + this.getHouse(); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.ALARM.ARMED, this.getHouse); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.ALARM.ARMING, this.arming); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.ALARM.DISARMED, this.getHouse); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.ALARM.PARTIALLY_ARMED, this.getHouse); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.ALARM.PANIC, this.getHouse); + } + + componentWillUnmount() { + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.ALARM.ARMED, this.getHouse); + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.ALARM.ARMING, this.getHouse); + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.ALARM.DISARMED, this.getHouse); + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.ALARM.PARTIALLY_ARMED, this.getHouse); + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.ALARM.PANIC, this.getHouse); + } + + componentDidUpdate(nextProps) { + const houseChanged = nextProps.box.house !== this.props.box.house; + if (houseChanged) { + this.getHouse(); + } + } + + render(props, { house, loading, arming }) { + const armingDisabled = (house && house.alarm_mode === ALARM_MODES.ARMED) || arming; + const partialArmDisabled = (house && house.alarm_mode === ALARM_MODES.PARTIALLY_ARMED) || arming; return (

{props.box.name}

-
-
-
- -
-
- + {house && ( +
+
+
+
+ {!arming && ( +

+ + + + + . +

+ )} + {arming && ( +

+ +

+ )} +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
-
-
- -
-
- -
-
-
+ )}
); } diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 1a641b1835..67bd73631a 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -365,7 +365,15 @@ "panicButton": "Panique", "editBoxNameLabel": "Nom du widget", "editBoxNamePlaceholder": "Entrez le nom du widget", - "editHouseLabel": "Sélectionnez la maison qui est concernée pour l'activation/désactivation de l'alarme." + "editHouseLabel": "Sélectionnez la maison qui est concernée pour l'activation/désactivation de l'alarme.", + "alarmStatusText": "Votre maison est ", + "alarmModes": { + "armed": "armée", + "disarmed": "désarmée", + "partially-armed": "armée partiellement", + "panic": "en mode panique" + }, + "alarmArming": "Votre maison est entrain d'être armée... Vous avez {{count}} secondes pour quitter la maison." } } }, From 43fde5d7bffba1a1dde266ef17747401d4ccb2ed Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 20 Oct 2023 11:41:03 +0200 Subject: [PATCH 07/44] Add alarm in scenes --- front/src/components/boxs/alarm/Alarm.jsx | 2 +- front/src/config/i18n/fr.json | 35 ++++- .../routes/scene/edit-scene/ActionCard.jsx | 12 +- .../routes/scene/edit-scene/TriggerCard.jsx | 39 ++++-- .../edit-scene/actions/CheckAlarmMode.jsx | 123 ++++++++++++++++++ .../actions/ChooseActionTypeCard.jsx | 3 +- .../edit-scene/triggers/AlarmModeTrigger.jsx | 69 ++++++++++ .../triggers/ChooseTriggerTypeCard.jsx | 6 +- server/models/scene.js | 3 +- server/utils/constants.js | 3 + 10 files changed, 273 insertions(+), 22 deletions(-) create mode 100644 front/src/routes/scene/edit-scene/actions/CheckAlarmMode.jsx create mode 100644 front/src/routes/scene/edit-scene/triggers/AlarmModeTrigger.jsx diff --git a/front/src/components/boxs/alarm/Alarm.jsx b/front/src/components/boxs/alarm/Alarm.jsx index e95bc48dd0..aaebee128c 100644 --- a/front/src/components/boxs/alarm/Alarm.jsx +++ b/front/src/components/boxs/alarm/Alarm.jsx @@ -88,7 +88,7 @@ class AlarmComponent extends Component {

- + .

diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 67bd73631a..6b30bb0550 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -367,12 +367,6 @@ "editBoxNamePlaceholder": "Entrez le nom du widget", "editHouseLabel": "Sélectionnez la maison qui est concernée pour l'activation/désactivation de l'alarme.", "alarmStatusText": "Votre maison est ", - "alarmModes": { - "armed": "armée", - "disarmed": "désarmée", - "partially-armed": "armée partiellement", - "panic": "en mode panique" - }, "alarmArming": "Votre maison est entrain d'être armée... Vous avez {{count}} secondes pour quitter la maison." } } @@ -1521,6 +1515,11 @@ "ok": "Réseau ok", "warning": "Réseau tendu", "critical": "Réseau très tendu" + }, + "alarmCheckMode": { + "description": "La scène continuera si l'alarme est dans le mode sélectionné.", + "houseLabel": "Maison", + "alarmModeLabel": "Mode de l'alarme" } }, "actions": { @@ -1571,6 +1570,9 @@ }, "ecowatt": { "condition": "Condition sur Ecowatt ( France )" + }, + "alarm": { + "check-alarm-mode": "Si l'alarme est en mode" } }, "variables": { @@ -1610,6 +1612,12 @@ }, "calendar": { "event-is-coming": "Un évènement dans le calendrier arrive" + }, + "alarm": { + "arm": "L'alarme est armée", + "disarm": "L'alarme est désarmée", + "partial-arm": "L'alarme est armée partiellement", + "panic": "L'alarme est en mode panique" } }, "triggersCard": { @@ -1694,6 +1702,15 @@ "minute": "minutes", "hour": "heures", "day": "jours" + }, + "alarmMode": { + "alarm": { + "arm": "Ce déclencheur se lancera quand la maison sélectionnée est armée.", + "disarm": "Ce déclencheur se lancera quand la maison sélectionnée est désarmée.", + "partial-arm": "Ce déclencheur se lancera quand la maison sélectionnée est armée partiellement.", + "panic": "Ce déclencheur se lancera quand la maison sélectionnée est en mode panique." + }, + "houseLabel": "Maison" } } }, @@ -2058,6 +2075,12 @@ "notConfigured": "Gladys Plus doit être configuré pour activer les sauvegardes. Accédez à l'onglet Gladys Plus pour le configurer.", "restoreFailed": "La restauration de votre backup a échoué. Êtes-vous sûr que votre clé de sauvegarde est la même que celle utilisée par Gladys lorsque le backup a été créé ? Vous pouvez modifier la clé de sauvegarde dans l'onglet Gladys Plus des paramètres." }, + "alarmModes": { + "armed": "armée", + "disarmed": "désarmée", + "partially-armed": "armée partiellement", + "panic": "en mode panique" + }, "deviceFeatureUnit": { "celsius": "Celsius (°C)", "fahrenheit": "Fahrenheit (°F)", diff --git a/front/src/routes/scene/edit-scene/ActionCard.jsx b/front/src/routes/scene/edit-scene/ActionCard.jsx index 8dfd856eae..5ce52bc8e5 100644 --- a/front/src/routes/scene/edit-scene/ActionCard.jsx +++ b/front/src/routes/scene/edit-scene/ActionCard.jsx @@ -25,6 +25,7 @@ import HouseEmptyOrNotCondition from './actions/HouseEmptyOrNotCondition'; import CalendarIsEventRunning from './actions/CalendarIsEventRunning'; import EcowattCondition from './actions/EcowattCondition'; import SendMessageCameraParams from './actions/SendMessageCameraParams'; +import CheckAlarmMode from './actions/CheckAlarmMode'; const deleteActionFromColumn = (columnIndex, rowIndex, deleteAction) => () => { deleteAction(columnIndex, rowIndex); @@ -52,7 +53,8 @@ const ACTION_ICON = { [ACTIONS.HOUSE.IS_NOT_EMPTY]: 'fe fe-home', [ACTIONS.DEVICE.SET_VALUE]: 'fe fe-radio', [ACTIONS.CALENDAR.IS_EVENT_RUNNING]: 'fe fe-calendar', - [ACTIONS.ECOWATT.CONDITION]: 'fe fe-zap' + [ACTIONS.ECOWATT.CONDITION]: 'fe fe-zap', + [ACTIONS.ALARM.CHECK_ALARM_MODE]: 'fe fe-bell' }; const ACTION_CARD_TYPE = 'ACTION_CARD_TYPE'; @@ -332,6 +334,14 @@ const ActionCard = ({ children, ...props }) => { updateActionProperty={props.updateActionProperty} /> )} + {props.action.type === ACTIONS.ALARM.CHECK_ALARM_MODE && ( + + )}
diff --git a/front/src/routes/scene/edit-scene/TriggerCard.jsx b/front/src/routes/scene/edit-scene/TriggerCard.jsx index 32d3384543..6eb64f6b4e 100644 --- a/front/src/routes/scene/edit-scene/TriggerCard.jsx +++ b/front/src/routes/scene/edit-scene/TriggerCard.jsx @@ -10,9 +10,27 @@ import UserPresenceTrigger from './triggers/UserPresenceTrigger'; import HouseEmptyOrNot from './triggers/HouseEmptyOrNot'; import UserEnteredOrLeftArea from './triggers/UserEnteredOrLeftArea'; import CalendarEventIsComing from './triggers/CalendarEventIsComing'; +import AlarmModeTrigger from './triggers/AlarmModeTrigger'; import { EVENTS } from '../../../../../server/utils/constants'; +const TRIGGER_ICON = { + [EVENTS.DEVICE.NEW_STATE]: 'fe-activity', + [EVENTS.TIME.CHANGED]: 'fe-watch', + [EVENTS.TIME.SUNSET]: 'fe-sunset', + [EVENTS.TIME.SUNRISE]: 'fe-sunrise', + [EVENTS.USER_PRESENCE.BACK_HOME]: 'fe-home', + [EVENTS.USER_PRESENCE.LEFT_HOME]: 'fe-home', + [EVENTS.HOUSE.NO_LONGER_EMPTY]: 'fe-home', + [EVENTS.AREA.USER_ENTERED]: 'fe-compass', + [EVENTS.AREA.USER_LEFT]: 'fe-compass', + [EVENTS.CALENDAR.EVENT_IS_COMING]: 'fe-calendar', + [EVENTS.ALARM.ARM]: 'fe-bell', + [EVENTS.ALARM.PARTIAL_ARM]: 'fe-bell', + [EVENTS.ALARM.DISARM]: 'fe-bell-off', + [EVENTS.ALARM.PANIC]: 'fe-alert-triangle' +}; + const deleteTriggerFromList = (deleteTrigger, index) => () => { deleteTrigger(index); }; @@ -20,17 +38,7 @@ const deleteTriggerFromList = (deleteTrigger, index) => () => { const TriggerCard = ({ children, ...props }) => (
- {props.trigger.type === EVENTS.DEVICE.NEW_STATE && } - {props.trigger.type === EVENTS.TIME.CHANGED && } - {props.trigger.type === EVENTS.TIME.SUNSET && } - {props.trigger.type === EVENTS.TIME.SUNRISE && } - {props.trigger.type === EVENTS.USER_PRESENCE.BACK_HOME && } - {props.trigger.type === EVENTS.USER_PRESENCE.LEFT_HOME && } - {props.trigger.type === EVENTS.HOUSE.EMPTY && } - {props.trigger.type === EVENTS.HOUSE.NO_LONGER_EMPTY && } - {props.trigger.type === EVENTS.AREA.USER_ENTERED && } - {props.trigger.type === EVENTS.AREA.USER_LEFT && } - {props.trigger.type === EVENTS.CALENDAR.EVENT_IS_COMING && } + {TRIGGER_ICON[props.trigger.type] && } {props.trigger.type === null && }
@@ -131,6 +139,15 @@ const TriggerCard = ({ children, ...props }) => ( setVariablesTrigger={props.setVariablesTrigger} /> )} + {[EVENTS.ALARM.ARM, EVENTS.ALARM.DISARM, EVENTS.ALARM.PARTIAL_ARM, EVENTS.ALARM.PANIC].includes( + props.trigger.type + ) && ( + + )}
); diff --git a/front/src/routes/scene/edit-scene/actions/CheckAlarmMode.jsx b/front/src/routes/scene/edit-scene/actions/CheckAlarmMode.jsx new file mode 100644 index 0000000000..28bdffb2e1 --- /dev/null +++ b/front/src/routes/scene/edit-scene/actions/CheckAlarmMode.jsx @@ -0,0 +1,123 @@ +import Select from 'react-select'; +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import { Text } from 'preact-i18n'; +import withIntlAsProp from '../../../../utils/withIntlAsProp'; +import get from 'get-value'; + +import { ALARM_MODES_LIST } from '../../../../../../server/utils/constants'; + +const capitalizeFirstLetter = string => { + return string.charAt(0).toUpperCase() + string.slice(1); +}; + +class CheckAlarmMode extends Component { + getOptions = async () => { + try { + const houses = await this.props.httpClient.get('/api/v1/house'); + const houseOptions = []; + houses.forEach(house => { + houseOptions.push({ + label: house.name, + value: house.selector + }); + }); + await this.setState({ houseOptions }); + this.refreshSelectedOptions(this.props); + } catch (e) { + console.error(e); + } + }; + handleHouseChange = selectedOption => { + if (selectedOption && selectedOption.value) { + this.props.updateActionProperty(this.props.columnIndex, this.props.index, 'house', selectedOption.value); + } else { + this.props.updateActionProperty(this.props.columnIndex, this.props.index, 'house', null); + } + }; + handleAlarmModeChange = selectedOption => { + if (selectedOption && selectedOption.value) { + this.props.updateActionProperty(this.props.columnIndex, this.props.index, 'alarm_mode', selectedOption.value); + } else { + this.props.updateActionProperty(this.props.columnIndex, this.props.index, 'alarm_mode', null); + } + }; + refreshSelectedOptions = nextProps => { + let selectedHouseOption = ''; + if (nextProps.action.house && this.state.houseOptions) { + const houseOption = this.state.houseOptions.find(option => option.value === nextProps.action.house); + + if (houseOption) { + selectedHouseOption = houseOption; + } + } + let selectedAlarmModeOption = ''; + if (nextProps.action.alarm_mode && this.state.alarmModesOptions) { + const alarmModeOption = this.state.alarmModesOptions.find(option => option.value === nextProps.action.alarm_mode); + + if (alarmModeOption) { + selectedAlarmModeOption = alarmModeOption; + } + } + this.setState({ selectedHouseOption, selectedAlarmModeOption }); + }; + constructor(props) { + super(props); + this.props = props; + const alarmModesOptions = ALARM_MODES_LIST.map(alarmMode => { + return { + value: alarmMode, + label: capitalizeFirstLetter(get(props.intl.dictionary, `alarmModes.${alarmMode}`, { default: alarmMode })) + }; + }); + this.state = { + alarmModesOptions, + selectedHouseOption: '' + }; + } + componentDidMount() { + this.getOptions(); + } + componentWillReceiveProps(nextProps) { + this.refreshSelectedOptions(nextProps); + } + render(props, { alarmModesOptions, houseOptions, selectedHouseOption, selectedAlarmModeOption }) { + return ( +
+

+ +

+
+ + +
+
+ ); + } +} + +export default withIntlAsProp(connect('httpClient', {})(CheckAlarmMode)); diff --git a/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx b/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx index 350fa3a1e5..8574af8627 100644 --- a/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx +++ b/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx @@ -27,7 +27,8 @@ const ACTION_LIST = [ ACTIONS.HOUSE.IS_NOT_EMPTY, ACTIONS.DEVICE.SET_VALUE, ACTIONS.CALENDAR.IS_EVENT_RUNNING, - ACTIONS.ECOWATT.CONDITION + ACTIONS.ECOWATT.CONDITION, + ACTIONS.ALARM.CHECK_ALARM_MODE ]; const TRANSLATIONS = ACTION_LIST.reduce((acc, action) => { diff --git a/front/src/routes/scene/edit-scene/triggers/AlarmModeTrigger.jsx b/front/src/routes/scene/edit-scene/triggers/AlarmModeTrigger.jsx new file mode 100644 index 0000000000..467de763de --- /dev/null +++ b/front/src/routes/scene/edit-scene/triggers/AlarmModeTrigger.jsx @@ -0,0 +1,69 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import { Text } from 'preact-i18n'; + +import 'react-datepicker/dist/react-datepicker.css'; +import { RequestStatus } from '../../../../utils/consts'; +import { EVENTS } from '../../../../../../server/utils/constants'; + +class AlarmModeTrigger extends Component { + getHouses = async () => { + this.setState({ + SceneGetHouses: RequestStatus.Getting + }); + try { + const houses = await this.props.httpClient.get('/api/v1/house'); + this.setState({ + houses, + SceneGetHouses: RequestStatus.Success + }); + } catch (e) { + this.setState({ + SceneGetHouses: RequestStatus.Error + }); + } + }; + + onHouseChange = e => { + this.props.updateTriggerProperty(this.props.index, 'house', e.target.value); + }; + + constructor(props) { + super(props); + this.state = { + houses: [] + }; + } + + componentDidMount() { + this.getHouses(); + } + + render({}, { houses }) { + return ( +
+

+ +

+
+
+ +
+ +
+
+ ); + } +} + +export default connect('httpClient,user', {})(AlarmModeTrigger); diff --git a/front/src/routes/scene/edit-scene/triggers/ChooseTriggerTypeCard.jsx b/front/src/routes/scene/edit-scene/triggers/ChooseTriggerTypeCard.jsx index 07684edae5..aa779ee7f1 100644 --- a/front/src/routes/scene/edit-scene/triggers/ChooseTriggerTypeCard.jsx +++ b/front/src/routes/scene/edit-scene/triggers/ChooseTriggerTypeCard.jsx @@ -18,7 +18,11 @@ const TRIGGER_LIST = [ EVENTS.HOUSE.NO_LONGER_EMPTY, EVENTS.AREA.USER_ENTERED, EVENTS.AREA.USER_LEFT, - EVENTS.CALENDAR.EVENT_IS_COMING + EVENTS.CALENDAR.EVENT_IS_COMING, + EVENTS.ALARM.ARM, + EVENTS.ALARM.DISARM, + EVENTS.ALARM.PANIC, + EVENTS.ALARM.PARTIAL_ARM ]; class ChooseTriggerType extends Component { diff --git a/server/models/scene.js b/server/models/scene.js index f6eb6b8746..4d1e53b854 100644 --- a/server/models/scene.js +++ b/server/models/scene.js @@ -1,5 +1,5 @@ const Joi = require('@hapi/joi').extend(require('@hapi/joi-date')); -const { ACTION_LIST, EVENT_LIST } = require('../utils/constants'); +const { ACTION_LIST, EVENT_LIST, ALARM_MODES_LIST } = require('../utils/constants'); const { addSelector } = require('../utils/addSelector'); const iconList = require('../config/icons.json'); @@ -57,6 +57,7 @@ const actionSchema = Joi.array().items( value: Joi.number(), evaluate_value: Joi.string(), }), + alarm_mode: Joi.string().valid(...ALARM_MODES_LIST), }), ), ); diff --git a/server/utils/constants.js b/server/utils/constants.js index e84c9098df..2c369c4c9e 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -291,6 +291,9 @@ const CONDITIONS = { }; const ACTIONS = { + ALARM: { + CHECK_ALARM_MODE: 'alarm.check-alarm-mode', + }, CALENDAR: { IS_EVENT_RUNNING: 'calendar.is-event-running', }, From 4dfa7b40c7b5204c311eb0785201befbe994448b Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 20 Oct 2023 14:14:31 +0200 Subject: [PATCH 08/44] Add scene actions/trigger --- server/lib/scene/scene.actions.js | 6 + server/lib/scene/scene.triggers.js | 4 + .../scene.action.checkAlarmMode.test.js | 69 ++++++ .../test/lib/scene/scene.checkTrigger.test.js | 3 + .../triggers/scene.trigger.alarmMode.test.js | 218 ++++++++++++++++++ 5 files changed, 300 insertions(+) create mode 100644 server/test/lib/scene/actions/scene.action.checkAlarmMode.test.js create mode 100644 server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js diff --git a/server/lib/scene/scene.actions.js b/server/lib/scene/scene.actions.js index a7ade930f4..d4138422da 100644 --- a/server/lib/scene/scene.actions.js +++ b/server/lib/scene/scene.actions.js @@ -437,6 +437,12 @@ const actionsFunc = { throw new AbortScene(e.message); } }, + [ACTIONS.ALARM.CHECK_ALARM_MODE]: async (self, action) => { + const house = await self.house.getBySelector(action.house); + if (house.alarm_mode !== action.alarm_mode) { + throw new AbortScene(`House "${house.name}" is not in mode ${action.alarm_mode}`); + } + }, }; module.exports = { diff --git a/server/lib/scene/scene.triggers.js b/server/lib/scene/scene.triggers.js index ed8134ef9d..c972f019b1 100644 --- a/server/lib/scene/scene.triggers.js +++ b/server/lib/scene/scene.triggers.js @@ -25,6 +25,10 @@ const triggersFunc = { [EVENTS.HOUSE.NO_LONGER_EMPTY]: (event, trigger) => event.house === trigger.house, [EVENTS.AREA.USER_ENTERED]: (event, trigger) => event.user === trigger.user && event.area === trigger.area, [EVENTS.AREA.USER_LEFT]: (event, trigger) => event.user === trigger.user && event.area === trigger.area, + [EVENTS.ALARM.ARM]: (event, trigger) => event.house === trigger.house, + [EVENTS.ALARM.DISARM]: (event, trigger) => event.house === trigger.house, + [EVENTS.ALARM.PARTIAL_ARM]: (event, trigger) => event.house === trigger.house, + [EVENTS.ALARM.PANIC]: (event, trigger) => event.house === trigger.house, }; module.exports = { diff --git a/server/test/lib/scene/actions/scene.action.checkAlarmMode.test.js b/server/test/lib/scene/actions/scene.action.checkAlarmMode.test.js new file mode 100644 index 0000000000..5ad3cf00c7 --- /dev/null +++ b/server/test/lib/scene/actions/scene.action.checkAlarmMode.test.js @@ -0,0 +1,69 @@ +const { fake } = require('sinon'); +const EventEmitter = require('events'); +const chaiAssert = require('chai').assert; + +const { ACTIONS } = require('../../../../utils/constants'); +const { AbortScene } = require('../../../../utils/coreErrors'); +const { executeActions } = require('../../../../lib/scene/scene.executeActions'); + +const StateManager = require('../../../../lib/state'); + +describe('scene.check-alarm-mode', () => { + let event; + let stateManager; + + beforeEach(() => { + event = new EventEmitter(); + stateManager = new StateManager(event); + }); + + it('should abort scene, condition is not verified', async () => { + stateManager.setState('deviceFeature', 'my-device-feature', { + category: 'light', + type: 'binary', + last_value: 15, + }); + const house = { + getBySelector: fake.resolves({ name: 'my house', alarm_mode: 'armed' }), + }; + const scope = {}; + const promise = executeActions( + { stateManager, event, house }, + [ + [ + { + type: ACTIONS.ALARM.CHECK_ALARM_MODE, + house: 'my-house', + alarm_mode: 'disarmed', + }, + ], + ], + scope, + ); + return chaiAssert.isRejected(promise, AbortScene, 'House "my house" is not in mode disarmed'); + }); + it('should continue scene, condition is verified', async () => { + stateManager.setState('deviceFeature', 'my-device-feature', { + category: 'light', + type: 'binary', + last_value: 15, + }); + const house = { + getBySelector: fake.resolves({ name: 'my house', alarm_mode: 'armed' }), + }; + const scope = {}; + await executeActions( + { stateManager, event, house }, + [ + [ + { + type: ACTIONS.ALARM.CHECK_ALARM_MODE, + house: 'my-house', + alarm_mode: 'armed', + }, + ], + ], + scope, + ); + }); +}); diff --git a/server/test/lib/scene/scene.checkTrigger.test.js b/server/test/lib/scene/scene.checkTrigger.test.js index 458586be1e..338e1722d8 100644 --- a/server/test/lib/scene/scene.checkTrigger.test.js +++ b/server/test/lib/scene/scene.checkTrigger.test.js @@ -10,6 +10,9 @@ const StateManager = require('../../../lib/state'); const event = new EventEmitter(); +// We are slowly moving this file to +// the "triggers" folder to have smaller test files + describe('scene.checkTrigger', () => { let sceneManager; diff --git a/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js b/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js new file mode 100644 index 0000000000..2265f9742c --- /dev/null +++ b/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js @@ -0,0 +1,218 @@ +const sinon = require('sinon'); +const EventEmitter = require('events'); + +const { assert, fake } = sinon; + +const { EVENTS, ACTIONS } = require('../../../../utils/constants'); +const SceneManager = require('../../../../lib/scene'); +const StateManager = require('../../../../lib/state'); + +const event = new EventEmitter(); + +describe('Scene.triggers.alarmMode', () => { + let sceneManager; + + const device = { + setValue: fake.resolves(null), + }; + + const brain = {}; + + beforeEach(() => { + const house = { + get: fake.resolves([]), + }; + + const scheduler = { + scheduleJob: (date, callback) => { + return { + callback, + date, + cancel: () => {}, + }; + }, + }; + + brain.addNamedEntity = fake.returns(null); + brain.removeNamedEntity = fake.returns(null); + + const stateManager = new StateManager(); + + sceneManager = new SceneManager(stateManager, event, device, {}, {}, house, {}, {}, {}, scheduler, brain); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should execute scene with alarm.arm trigger', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_OFF, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.ALARM.ARM, + house: 'house-1', + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.ALARM.ARM, + house: 'house-1', + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.calledOnce(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + it('should execute scene with alarm.disarm trigger', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_OFF, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.ALARM.DISARM, + house: 'house-1', + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.ALARM.DISARM, + house: 'house-1', + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.calledOnce(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + it('should execute scene with alarm.partial-arm trigger', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_OFF, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.ALARM.PARTIAL_ARM, + house: 'house-1', + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.ALARM.PARTIAL_ARM, + house: 'house-1', + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.calledOnce(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + it('should execute scene with alarm.panic trigger', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_OFF, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.ALARM.PANIC, + house: 'house-1', + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.ALARM.PANIC, + house: 'house-1', + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.calledOnce(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + it('should not execute scene (house not matching)', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_OFF, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.ALARM.ARM, + house: 'house-2', + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.ALARM.ARM, + house: 'house-1', + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.notCalled(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); +}); From a224f5eb2bbd7951c86b6878149dc123944a0e62 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 20 Oct 2023 14:44:23 +0200 Subject: [PATCH 09/44] Fix tests --- server/models/house.js | 3 ++- server/test/controllers/house/house.test.js | 15 +++++++++++++++ server/test/controllers/user/user.get.test.js | 3 +++ server/test/controllers/weather/weather.test.js | 3 +++ server/test/lib/house/house.arm.test.js | 3 +++ server/test/lib/house/house.test.js | 9 +++++++++ 6 files changed, 35 insertions(+), 1 deletion(-) diff --git a/server/models/house.js b/server/models/house.js index bf0bcc0df0..6ae44e355f 100644 --- a/server/models/house.js +++ b/server/models/house.js @@ -1,5 +1,5 @@ const { addSelector } = require('../utils/addSelector'); -const { ALARM_MODES_LIST } = require('../utils/constants'); +const { ALARM_MODES_LIST, ALARM_MODES } = require('../utils/constants'); module.exports = (sequelize, DataTypes) => { const house = sequelize.define( @@ -34,6 +34,7 @@ module.exports = (sequelize, DataTypes) => { alarm_mode: { allowNull: false, type: DataTypes.ENUM(ALARM_MODES_LIST), + defaultValue: ALARM_MODES.DISARMED, }, alarm_code: { allowNull: true, diff --git a/server/test/controllers/house/house.test.js b/server/test/controllers/house/house.test.js index aa412376a8..39cc98a421 100644 --- a/server/test/controllers/house/house.test.js +++ b/server/test/controllers/house/house.test.js @@ -29,6 +29,9 @@ describe('GET /api/v1/house', () => { id: '6295ad8b-b655-4422-9e6d-b4612da5d55f', name: 'Peppers house', selector: 'pepper-house', + alarm_code: null, + alarm_delay_before_arming: 10, + alarm_mode: 'disarmed', latitude: null, longitude: null, created_at: '2019-02-12T07:49:07.556Z', @@ -38,6 +41,9 @@ describe('GET /api/v1/house', () => { id: 'a741dfa6-24de-4b46-afc7-370772f068d5', name: 'Test house', selector: 'test-house', + alarm_code: null, + alarm_delay_before_arming: 10, + alarm_mode: 'disarmed', latitude: 12, longitude: 12, created_at: '2019-02-12T07:49:07.556Z', @@ -60,6 +66,9 @@ describe('GET /api/v1/house', () => { id: 'a741dfa6-24de-4b46-afc7-370772f068d5', name: 'Test house', selector: 'test-house', + alarm_code: null, + alarm_delay_before_arming: 10, + alarm_mode: 'disarmed', latitude: 12, longitude: 12, created_at: '2019-02-12T07:49:07.556Z', @@ -69,6 +78,9 @@ describe('GET /api/v1/house', () => { id: '6295ad8b-b655-4422-9e6d-b4612da5d55f', name: 'Peppers house', selector: 'pepper-house', + alarm_code: null, + alarm_delay_before_arming: 10, + alarm_mode: 'disarmed', latitude: null, longitude: null, created_at: '2019-02-12T07:49:07.556Z', @@ -91,6 +103,9 @@ describe('GET /api/v1/house', () => { id: 'a741dfa6-24de-4b46-afc7-370772f068d5', name: 'Test house', selector: 'test-house', + alarm_code: null, + alarm_delay_before_arming: 10, + alarm_mode: 'disarmed', latitude: 12, longitude: 12, created_at: '2019-02-12T07:49:07.556Z', diff --git a/server/test/controllers/user/user.get.test.js b/server/test/controllers/user/user.get.test.js index a5577567d8..951c93d818 100644 --- a/server/test/controllers/user/user.get.test.js +++ b/server/test/controllers/user/user.get.test.js @@ -52,6 +52,9 @@ describe('GET /api/v1/user', () => { id: '6295ad8b-b655-4422-9e6d-b4612da5d55f', name: 'Peppers house', selector: 'pepper-house', + alarm_code: null, + alarm_delay_before_arming: 10, + alarm_mode: 'disarmed', latitude: null, longitude: null, created_at: '2019-02-12T07:49:07.556Z', diff --git a/server/test/controllers/weather/weather.test.js b/server/test/controllers/weather/weather.test.js index 4b3172cc34..94013a4a9f 100644 --- a/server/test/controllers/weather/weather.test.js +++ b/server/test/controllers/weather/weather.test.js @@ -52,6 +52,9 @@ describe('GET /api/v1/house/:selector/weather', () => { id: 'a741dfa6-24de-4b46-afc7-370772f068d5', name: 'Test house', selector: 'test-house', + alarm_code: null, + alarm_delay_before_arming: 10, + alarm_mode: 'disarmed', latitude: 12, longitude: 12, created_at: '2019-02-12T07:49:07.556Z', diff --git a/server/test/lib/house/house.arm.test.js b/server/test/lib/house/house.arm.test.js index a60c3ff7d0..9c06f94809 100644 --- a/server/test/lib/house/house.arm.test.js +++ b/server/test/lib/house/house.arm.test.js @@ -1,4 +1,5 @@ const { expect } = require('chai'); +const Promise = require('bluebird'); const assertChai = require('chai').assert; const sinon = require('sinon'); @@ -25,6 +26,7 @@ describe('house.arm', () => { }); it('should arm a house', async () => { await house.arm('test-house'); + await Promise.delay(5); assert.calledThrice(event.emit); expect(event.emit.firstCall.args).to.deep.equal([ EVENTS.WEBSOCKET.SEND_ALL, @@ -58,6 +60,7 @@ describe('house.arm', () => { }); it('should return house is already armed error', async () => { await house.arm('test-house'); + await Promise.delay(5); const promise = house.arm('test-house'); return assertChai.isRejected(promise, 'House is already armed'); }); diff --git a/server/test/lib/house/house.test.js b/server/test/lib/house/house.test.js index 799dc70eb3..d250f7f108 100644 --- a/server/test/lib/house/house.test.js +++ b/server/test/lib/house/house.test.js @@ -84,6 +84,9 @@ describe('House', () => { id: '6295ad8b-b655-4422-9e6d-b4612da5d55f', name: 'Peppers house', selector: 'pepper-house', + alarm_code: null, + alarm_delay_before_arming: 10, + alarm_mode: 'disarmed', latitude: null, longitude: null, created_at: new Date('2019-02-12T07:49:07.556Z'), @@ -93,6 +96,9 @@ describe('House', () => { id: 'a741dfa6-24de-4b46-afc7-370772f068d5', name: 'Test house', selector: 'test-house', + alarm_code: null, + alarm_delay_before_arming: 10, + alarm_mode: 'disarmed', latitude: 12, longitude: 12, created_at: new Date('2019-02-12T07:49:07.556Z'), @@ -315,6 +321,9 @@ describe('House', () => { id: 'a741dfa6-24de-4b46-afc7-370772f068d5', name: 'Test house', selector: 'test-house', + alarm_code: null, + alarm_delay_before_arming: 10, + alarm_mode: 'disarmed', latitude: 12, longitude: 12, created_at: new Date('2019-02-12T07:49:07.556Z'), From d6220cd7595edd403e02addf329d0a74bbde3e71 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 20 Oct 2023 14:49:20 +0200 Subject: [PATCH 10/44] Fix test --- server/test/lib/user/user.get.test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/test/lib/user/user.get.test.js b/server/test/lib/user/user.get.test.js index 42e9c4bfa5..290c0a65b9 100644 --- a/server/test/lib/user/user.get.test.js +++ b/server/test/lib/user/user.get.test.js @@ -25,6 +25,9 @@ describe('user.get', () => { id: '6295ad8b-b655-4422-9e6d-b4612da5d55f', name: 'Peppers house', selector: 'pepper-house', + alarm_code: null, + alarm_delay_before_arming: 10, + alarm_mode: 'disarmed', latitude: null, longitude: null, created_at: new Date('2019-02-12T07:49:07.556Z'), From fe9b143674251a36b3facc834889821b2c29cb58 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 20 Oct 2023 14:50:10 +0200 Subject: [PATCH 11/44] Fix eslint front --- front/src/routes/scene/edit-scene/triggers/AlarmModeTrigger.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/front/src/routes/scene/edit-scene/triggers/AlarmModeTrigger.jsx b/front/src/routes/scene/edit-scene/triggers/AlarmModeTrigger.jsx index 467de763de..ad8859800d 100644 --- a/front/src/routes/scene/edit-scene/triggers/AlarmModeTrigger.jsx +++ b/front/src/routes/scene/edit-scene/triggers/AlarmModeTrigger.jsx @@ -4,7 +4,6 @@ import { Text } from 'preact-i18n'; import 'react-datepicker/dist/react-datepicker.css'; import { RequestStatus } from '../../../../utils/consts'; -import { EVENTS } from '../../../../../../server/utils/constants'; class AlarmModeTrigger extends Component { getHouses = async () => { From bc2a6f39923ca923e0a4221318429fc84c3834f1 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 20 Oct 2023 16:37:24 +0200 Subject: [PATCH 12/44] Add all tablet logic --- server/api/controllers/house.controller.js | 11 +++ server/api/routes.js | 4 + server/api/setupRoutes.js | 5 ++ server/lib/house/house.arm.js | 2 + server/lib/house/house.disarm.js | 2 + server/lib/house/house.disarmWithCode.js | 13 +-- server/lib/house/index.js | 3 +- server/lib/index.js | 4 +- server/lib/session/index.js | 4 + server/lib/session/session.getAccessToken.js | 7 ++ .../session/session.setTabletModeLocked.js | 38 +++++++++ .../lib/session/session.unlockTabletMode.js | 38 +++++++++ .../session/session.validateAccessToken.js | 11 ++- .../migrations/20230929085337-alarm-mode.js | 5 ++ server/models/session.js | 23 ++++++ .../controllers/house/house.alarm.test.js | 64 +++++++++++++++ server/test/controllers/request.test.js | 8 ++ .../lib/house/house.disarmWithCode.test.js | 5 +- server/test/lib/session/session.test.js | 79 ++++++++++++++++++- 19 files changed, 312 insertions(+), 14 deletions(-) create mode 100644 server/lib/session/session.setTabletModeLocked.js create mode 100644 server/lib/session/session.unlockTabletMode.js create mode 100644 server/test/controllers/house/house.alarm.test.js diff --git a/server/api/controllers/house.controller.js b/server/api/controllers/house.controller.js index 625f667206..af0d57ceb0 100644 --- a/server/api/controllers/house.controller.js +++ b/server/api/controllers/house.controller.js @@ -147,6 +147,16 @@ module.exports = function HouseController(gladys) { res.json(house); } + /** + * @api {post} /api/v1/house/:house_selector/disarm_with_code DisarmWithCode + * @apiName DisarmWithCode + * @apiGroup Alarm + */ + async function disarmWithCode(req, res) { + const house = await gladys.house.disarmWithCode(req.params.house_selector, req.body.code); + res.json(house); + } + /** * @api {post} /api/v1/house/:house_selector/partial_arm Partial Arm * @apiName Partial Arm @@ -177,6 +187,7 @@ module.exports = function HouseController(gladys) { getRooms: asyncMiddleware(getRooms), arm: asyncMiddleware(arm), disarm: asyncMiddleware(disarm), + disarmWithCode: asyncMiddleware(disarmWithCode), partialArm: asyncMiddleware(partialArm), panic: asyncMiddleware(panic), }); diff --git a/server/api/routes.js b/server/api/routes.js index dc62ec1749..9e52c5e397 100644 --- a/server/api/routes.js +++ b/server/api/routes.js @@ -264,6 +264,10 @@ function getRoutes(gladys) { authenticated: true, controller: houseController.disarm, }, + 'post /api/v1/house/:house_selector/disarm_with_code': { + alarmAuth: true, + controller: houseController.disarmWithCode, + }, 'post /api/v1/house/:house_selector/partial_arm': { authenticated: true, controller: houseController.partialArm, diff --git a/server/api/setupRoutes.js b/server/api/setupRoutes.js index da96a7de88..7e12a1e3df 100644 --- a/server/api/setupRoutes.js +++ b/server/api/setupRoutes.js @@ -26,6 +26,7 @@ function setupRoutes(gladys) { const authMiddleware = AuthMiddleware('dashboard:write', gladys); const isInstanceConfiguredMiddleware = IsInstanceConfiguredMiddleware(gladys); const resetPasswordAuthMiddleware = AuthMiddleware('reset-password:write', gladys); + const alarmMiddleware = AuthMiddleware('alarm:write', gladys); // enable cross origin requests router.use(CorsMiddleware); @@ -57,6 +58,10 @@ function setupRoutes(gladys) { if (routes[routeKey].resetPasswordAuth) { routerParams.push(resetPasswordAuthMiddleware); } + // if the route need authentication for alarm + if (routes[routeKey].alarmAuth) { + routerParams.push(alarmMiddleware); + } // add the controller at the end of the array routerParams.push(routes[routeKey].controller); diff --git a/server/lib/house/house.arm.js b/server/lib/house/house.arm.js index 436869b641..cca3cda569 100644 --- a/server/lib/house/house.arm.js +++ b/server/lib/house/house.arm.js @@ -49,6 +49,8 @@ async function arm(selector) { house: selector, }, }); + // Lock all tablets in this house + await this.session.setTabletModeLocked(house.id); }, house.alarm_delay_before_arming * 1000); } diff --git a/server/lib/house/house.disarm.js b/server/lib/house/house.disarm.js index d85af2ccff..8d508947b3 100644 --- a/server/lib/house/house.disarm.js +++ b/server/lib/house/house.disarm.js @@ -39,6 +39,8 @@ async function disarm(selector) { house: selector, }, }); + // Unlock all tablets in this house + await this.session.unlockTabletMode(house.id); return house.get({ plain: true }); } diff --git a/server/lib/house/house.disarmWithCode.js b/server/lib/house/house.disarmWithCode.js index 7ae55ff4e1..4cd882e3d9 100644 --- a/server/lib/house/house.disarmWithCode.js +++ b/server/lib/house/house.disarmWithCode.js @@ -1,7 +1,7 @@ const Promise = require('bluebird'); const db = require('../../models'); const { ALARM_MODES } = require('../../utils/constants'); -const { NotFoundError, ConflictError, ForbiddenError } = require('../../utils/coreErrors'); +const { NotFoundError, ForbiddenError } = require('../../utils/coreErrors'); /** * @public @@ -23,16 +23,17 @@ async function disarmWithCode(selector, code) { throw new NotFoundError('House not found'); } - if (house.alarm_mode === ALARM_MODES.DISARMED) { - throw new ConflictError('House is already disarmed'); - } - if (house.alarm_code !== code) { throw new ForbiddenError('Invalid code'); } + // In this case, we don't throw an error if the house is already disarmed + if (house.alarm_mode === ALARM_MODES.DISARMED) { + return house.get({ plain: true }); + } + // Disarm house - await this.disarm(selector); + return this.disarm(selector); } module.exports = { diff --git a/server/lib/house/index.js b/server/lib/house/index.js index 8b9e2a7cfc..ac620e86d0 100644 --- a/server/lib/house/index.js +++ b/server/lib/house/index.js @@ -14,9 +14,10 @@ const { userLeft } = require('./house.userLeft'); const { userSeen } = require('./house.userSeen'); const { getBySelector } = require('./house.getBySelector'); -const House = function House(event, stateManager) { +const House = function House(event, stateManager, session) { this.event = event; this.stateManager = stateManager; + this.session = session; }; House.prototype.arm = arm; diff --git a/server/lib/index.js b/server/lib/index.js index dd83c1cbfe..6bce26adc3 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -56,13 +56,13 @@ function Gladys(params = {}) { const area = new Area(event); const dashboard = new Dashboard(); const stateManager = new StateManager(event); + const session = new Session(params.jwtSecret, cache); const system = new System(db.sequelize, event, config, job); const http = new Http(system); - const house = new House(event, stateManager); + const house = new House(event, stateManager, session); const room = new Room(brain); const service = new Service(services, stateManager); const message = new MessageHandler(event, brain, service, stateManager, variable); - const session = new Session(params.jwtSecret, cache); const user = new User(session, stateManager, variable); const location = new Location(user, event); const device = new Device(event, message, stateManager, service, room, variable, job); diff --git a/server/lib/session/index.js b/server/lib/session/index.js index 012ef27ce8..e9d9d3adbc 100644 --- a/server/lib/session/index.js +++ b/server/lib/session/index.js @@ -5,6 +5,8 @@ const { getAccessToken } = require('./session.getAccessToken'); const { validateAccessToken } = require('./session.validateAccessToken'); const { validateApiKey } = require('./session.validateApiKey'); const { revoke } = require('./session.revoke'); +const { setTabletModeLocked } = require('./session.setTabletModeLocked'); +const { unlockTabletMode } = require('./session.unlockTabletMode'); const Session = function Session(jwtSecret, cache) { this.jwtSecret = jwtSecret; @@ -18,5 +20,7 @@ Session.prototype.getAccessToken = getAccessToken; Session.prototype.validateAccessToken = validateAccessToken; Session.prototype.validateApiKey = validateApiKey; Session.prototype.revoke = revoke; +Session.prototype.setTabletModeLocked = setTabletModeLocked; +Session.prototype.unlockTabletMode = unlockTabletMode; module.exports = Session; diff --git a/server/lib/session/session.getAccessToken.js b/server/lib/session/session.getAccessToken.js index fb05280c0a..7f0754f8fc 100644 --- a/server/lib/session/session.getAccessToken.js +++ b/server/lib/session/session.getAccessToken.js @@ -38,6 +38,13 @@ async function getAccessToken(refreshToken, scope) { throw new Error401(`Session was revoked`); } + if (session.tablet_mode_locked) { + const scopeIsAlarmWrite = scope.length === 1 && scope[0] === 'alarm:write'; + if (!scopeIsAlarmWrite) { + throw new Error401('TABLET_IS_LOCKED'); + } + } + const accessToken = generateAccessToken(session.user_id, scope, session.id, this.jwtSecret); return { diff --git a/server/lib/session/session.setTabletModeLocked.js b/server/lib/session/session.setTabletModeLocked.js new file mode 100644 index 0000000000..24dfa868a8 --- /dev/null +++ b/server/lib/session/session.setTabletModeLocked.js @@ -0,0 +1,38 @@ +const Promise = require('bluebird'); + +const db = require('../../models'); + +/** + * @description Lock all sessions of all tablets of a house. + * @param {string} houseId - Id of the house. + * @returns {Promise} Return the revoked session. + * @example + * setTabletModeLocked('375223b3-71c6-4b61-a346-0a9d5baf12b4'); + */ +async function setTabletModeLocked(houseId) { + const sessions = await db.Session.findAll({ + attributes: ['id'], + where: { + current_house_id: houseId, + revoked: false, + tablet_mode: true, + }, + }); + + return Promise.mapSeries(sessions, async (session) => { + // Lock tablet + await session.update({ tablet_mode_locked: true }); + + // Set cache to locked + this.cache.set(`tablet_mode_locked:${session.id}`, true); + + return { + id: session.id, + tablet_mode_locked: true, + }; + }); +} + +module.exports = { + setTabletModeLocked, +}; diff --git a/server/lib/session/session.unlockTabletMode.js b/server/lib/session/session.unlockTabletMode.js new file mode 100644 index 0000000000..d82ee45812 --- /dev/null +++ b/server/lib/session/session.unlockTabletMode.js @@ -0,0 +1,38 @@ +const Promise = require('bluebird'); + +const db = require('../../models'); + +/** + * @description Unlock all sessions of all tablets of a house. + * @param {string} houseId - Id of the house. + * @returns {Promise} Return the list of sessions affected. + * @example + * setTabletModeLocked('375223b3-71c6-4b61-a346-0a9d5baf12b4'); + */ +async function unlockTabletMode(houseId) { + const sessions = await db.Session.findAll({ + attributes: ['id'], + where: { + current_house_id: houseId, + revoked: false, + tablet_mode: true, + }, + }); + + return Promise.mapSeries(sessions, async (session) => { + // Unlock tablet + await session.update({ tablet_mode_locked: false }); + + // Delete cache + this.cache.del(`tablet_mode_locked:${session.id}`); + + return { + id: session.id, + tablet_mode_locked: false, + }; + }); +} + +module.exports = { + unlockTabletMode, +}; diff --git a/server/lib/session/session.validateAccessToken.js b/server/lib/session/session.validateAccessToken.js index e4b4b72e8b..bfc4b64425 100644 --- a/server/lib/session/session.validateAccessToken.js +++ b/server/lib/session/session.validateAccessToken.js @@ -1,5 +1,5 @@ const jwt = require('jsonwebtoken'); -const { Error401 } = require('../../utils/httpErrors'); +const { Error401, Error403 } = require('../../utils/httpErrors'); /** * @description Validate an access token. @@ -28,6 +28,15 @@ function validateAccessToken(accessToken, scope) { throw new Error401('AuthMiddleware: Session was revoked'); } + // We verify that the session is not a tablet mode currently locked + if (this.cache.get(`tablet_mode_locked:${decoded.session_id}`) === true) { + // if the scope currently asked is the alarm scope, it's ok + const scopeIsAlarmWrite = scope === 'alarm:write'; + if (!scopeIsAlarmWrite) { + throw new Error403('TABLET_IS_LOCKED'); + } + } + return decoded; } diff --git a/server/migrations/20230929085337-alarm-mode.js b/server/migrations/20230929085337-alarm-mode.js index 4d3675ea55..0e16cac085 100644 --- a/server/migrations/20230929085337-alarm-mode.js +++ b/server/migrations/20230929085337-alarm-mode.js @@ -22,6 +22,11 @@ module.exports = { type: Sequelize.BOOLEAN, defaultValue: false, }); + await queryInterface.addColumn('t_session', 'tablet_mode_locked', { + allowNull: false, + type: Sequelize.BOOLEAN, + defaultValue: false, + }); await queryInterface.addColumn('t_session', 'current_house_id', { allowNull: true, type: Sequelize.UUID, diff --git a/server/models/session.js b/server/models/session.js index c210e5cac1..a4abb57588 100644 --- a/server/models/session.js +++ b/server/models/session.js @@ -43,6 +43,24 @@ module.exports = (sequelize, DataTypes) => { useragent: { type: DataTypes.TEXT, }, + tablet_mode: { + allowNull: false, + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + tablet_mode_locked: { + allowNull: false, + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + current_house_id: { + allowNull: true, + type: DataTypes.UUID, + references: { + model: 't_house', + key: 'id', + }, + }, }, {}, ); @@ -53,6 +71,11 @@ module.exports = (sequelize, DataTypes) => { targetKey: 'id', as: 'user', }); + session.belongsTo(models.House, { + foreignKey: 'current_house_id', + sourceKey: 'id', + as: 'current_house', + }); }; return session; diff --git a/server/test/controllers/house/house.alarm.test.js b/server/test/controllers/house/house.alarm.test.js new file mode 100644 index 0000000000..9a463e2c2f --- /dev/null +++ b/server/test/controllers/house/house.alarm.test.js @@ -0,0 +1,64 @@ +const { expect } = require('chai'); +const { authenticatedRequest, request, alarmModeToken } = require('../request.test'); +const db = require('../../../models'); + +describe('House.alarm', () => { + it('should arm house', async () => { + await authenticatedRequest + .post('/api/v1/house/test-house/arm') + .expect('Content-Type', /json/) + .expect(200); + }); + it('should disarm house', async () => { + const testHouse = await db.House.findOne({ + where: { + selector: 'test-house', + }, + }); + await testHouse.update({ alarm_mode: 'armed' }); + await authenticatedRequest + .post('/api/v1/house/test-house/disarm') + .expect('Content-Type', /json/) + .expect(200); + }); + it('should disarm house with code', async () => { + const testHouse = await db.House.findOne({ + where: { + selector: 'test-house', + }, + }); + await testHouse.update({ alarm_mode: 'armed', alarm_code: '123456' }); + const res = await request + .post('/api/v1/house/test-house/disarm_with_code') + .set('Authorization', `Bearer ${alarmModeToken}`) + .send({ + code: '123456', + refresh_token: 'refresh-token-test', + }) + .expect('Content-Type', /json/) + .expect(200); + expect(res.body).to.have.property('alarm_mode', 'disarmed'); + const resSecondCall = await request + .post('/api/v1/house/test-house/disarm_with_code') + .set('Authorization', `Bearer ${alarmModeToken}`) + .send({ + code: '123456', + refresh_token: 'refresh-token-test', + }) + .expect('Content-Type', /json/) + .expect(200); + expect(resSecondCall.body).to.have.property('alarm_mode', 'disarmed'); + }); + it('should partially arm house', async () => { + await authenticatedRequest + .post('/api/v1/house/test-house/partial_arm') + .expect('Content-Type', /json/) + .expect(200); + }); + it('should put a house in panic mode', async () => { + await authenticatedRequest + .post('/api/v1/house/test-house/panic') + .expect('Content-Type', /json/) + .expect(200); + }); +}); diff --git a/server/test/controllers/request.test.js b/server/test/controllers/request.test.js index 2b35dca50f..8300ed27a2 100644 --- a/server/test/controllers/request.test.js +++ b/server/test/controllers/request.test.js @@ -9,6 +9,13 @@ const token = generateAccessToken( ); const header = `Bearer ${token}`; +const alarmModeToken = generateAccessToken( + '0cd30aef-9c4e-4a23-88e3-3547971296e5', + ['alarm:write'], + 'baf1fa89-153b-4f2e-adf3-787e410ec291', + 'secret', +); + const authenticatedRequest = { // @ts-ignore get: (url) => @@ -62,4 +69,5 @@ const unAuthenticatedRequest = { module.exports = { authenticatedRequest, request: unAuthenticatedRequest, + alarmModeToken, }; diff --git a/server/test/lib/house/house.disarmWithCode.test.js b/server/test/lib/house/house.disarmWithCode.test.js index 3f7a054058..2f1826bb9a 100644 --- a/server/test/lib/house/house.disarmWithCode.test.js +++ b/server/test/lib/house/house.disarmWithCode.test.js @@ -53,9 +53,8 @@ describe('house.disarmWithCode', () => { const promise = house.disarmWithCode('test-house', '12'); return assertChai.isRejected(promise, 'Invalid code'); }); - it('should return house is already disarmed error', async () => { + it('should just resolve if house is already disarmed', async () => { + await house.disarmWithCode('test-house', '123456'); await house.disarmWithCode('test-house', '123456'); - const promise = house.disarmWithCode('test-house', '123456'); - return assertChai.isRejected(promise, 'House is already disarmed'); }); }); diff --git a/server/test/lib/session/session.test.js b/server/test/lib/session/session.test.js index 34b7f9a370..9bf7517a4f 100644 --- a/server/test/lib/session/session.test.js +++ b/server/test/lib/session/session.test.js @@ -1,5 +1,5 @@ const { expect, assert } = require('chai'); - +const db = require('../../../models'); const { Cache } = require('../../../utils/cache'); const Session = require('../../../lib/session'); @@ -68,6 +68,17 @@ describe('session.getAccessToken', () => { const promise = session.getAccessToken('does-not-exist', ['dashboard:read']); return assert.isRejected(promise, 'Session not found'); }); + it('should return bad request error, tablet mode is active (tablet locked)', async () => { + const oneSession = await db.Session.findOne({ + where: { + id: 'ada07710-5f25-4510-ac63-b002aca3bd32', + }, + }); + await oneSession.update({ tablet_mode_locked: true }); + const session = new Session('secret'); + const promise = session.getAccessToken('refresh-token-test', ['dashboard:read']); + return assert.isRejected(promise, 'TABLET_IS_LOCKED'); + }); }); describe('session.revoke', () => { @@ -90,6 +101,31 @@ describe('session.revoke', () => { }); }); +describe('session.validateAccessToken', () => { + it('should return error, tablet is locked', async () => { + const cache = new Cache(); + const session = new Session('secret', cache); + const accessTokenDashboard = await session.getAccessToken('refresh-token-test', ['dashboard:write']); + const accessTokenAlarm = await session.getAccessToken('refresh-token-test', ['alarm:write']); + const oneSession = await db.Session.findOne({ + where: { + id: 'ada07710-5f25-4510-ac63-b002aca3bd32', + }, + }); + await oneSession.update({ tablet_mode: true, current_house_id: 'a741dfa6-24de-4b46-afc7-370772f068d5' }); + await session.setTabletModeLocked('a741dfa6-24de-4b46-afc7-370772f068d5'); + try { + session.validateAccessToken(accessTokenDashboard.access_token, 'dashboard:write'); + assert.fail('should fail'); + } catch (e) { + expect(e.message).to.equal('TABLET_IS_LOCKED'); + } + const res = session.validateAccessToken(accessTokenAlarm.access_token, 'alarm:write'); + expect(res).to.have.property('scope'); + expect(res).to.have.property('user_id', '0cd30aef-9c4e-4a23-88e3-3547971296e5'); + }); +}); + describe('session.validateApiKey', () => { it('should validate an api key', async () => { const session = new Session('secret'); @@ -112,3 +148,44 @@ describe('session.validateApiKey', () => { return assert.isRejected(promise, 'Api key not found'); }); }); + +describe('session.setTabletModeLocked', () => { + const cache = new Cache(); + it('should lock one session', async () => { + const oneSession = await db.Session.findOne({ + where: { + id: 'ada07710-5f25-4510-ac63-b002aca3bd32', + }, + }); + await oneSession.update({ tablet_mode: true, current_house_id: 'a741dfa6-24de-4b46-afc7-370772f068d5' }); + const session = new Session('secret', cache); + const results = await session.setTabletModeLocked('a741dfa6-24de-4b46-afc7-370772f068d5'); + expect(results).to.deep.equal([ + { + id: 'ada07710-5f25-4510-ac63-b002aca3bd32', + tablet_mode_locked: true, + }, + ]); + }); +}); + +describe('session.unlockTabletMode', () => { + const cache = new Cache(); + it('should lock one session then unlock it', async () => { + const oneSession = await db.Session.findOne({ + where: { + id: 'ada07710-5f25-4510-ac63-b002aca3bd32', + }, + }); + await oneSession.update({ tablet_mode: true, current_house_id: 'a741dfa6-24de-4b46-afc7-370772f068d5' }); + const session = new Session('secret', cache); + await session.setTabletModeLocked('a741dfa6-24de-4b46-afc7-370772f068d5'); + const results = await session.unlockTabletMode('a741dfa6-24de-4b46-afc7-370772f068d5'); + expect(results).to.deep.equal([ + { + id: 'ada07710-5f25-4510-ac63-b002aca3bd32', + tablet_mode_locked: false, + }, + ]); + }); +}); From a5541ef589969a134372a570d7217ea4eeefbee2 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 20 Oct 2023 16:45:11 +0200 Subject: [PATCH 13/44] Fix tests --- server/test/lib/house/house.disarm.test.js | 6 +++++- server/test/lib/house/house.disarmWithCode.test.js | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/server/test/lib/house/house.disarm.test.js b/server/test/lib/house/house.disarm.test.js index 269ee6fbbd..7a1f4071f0 100644 --- a/server/test/lib/house/house.disarm.test.js +++ b/server/test/lib/house/house.disarm.test.js @@ -13,7 +13,10 @@ const event = { }; describe('house.disarm', () => { - const house = new House(event); + const session = { + unlockTabletMode: fake.resolves(null), + }; + const house = new House(event, {}, session); beforeEach(async () => { await house.update('test-house', { alarm_delay_before_arming: 0, @@ -27,6 +30,7 @@ describe('house.disarm', () => { it('should disarm a house', async () => { await house.disarm('test-house'); assert.calledTwice(event.emit); + assert.calledOnce(session.unlockTabletMode); expect(event.emit.firstCall.args).to.deep.equal([ EVENTS.TRIGGERS.CHECK, { diff --git a/server/test/lib/house/house.disarmWithCode.test.js b/server/test/lib/house/house.disarmWithCode.test.js index 2f1826bb9a..5858e68a58 100644 --- a/server/test/lib/house/house.disarmWithCode.test.js +++ b/server/test/lib/house/house.disarmWithCode.test.js @@ -13,7 +13,10 @@ const event = { }; describe('house.disarmWithCode', () => { - const house = new House(event); + const session = { + unlockTabletMode: fake.resolves(null), + }; + const house = new House(event, {}, session); beforeEach(async () => { await house.update('test-house', { alarm_code: '123456', From c4d0587bce3c5febe4c67832c28cd32b9b1452f2 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 20 Oct 2023 17:07:17 +0200 Subject: [PATCH 14/44] Add route to update session to tablet mode --- server/api/controllers/session.controller.js | 16 +++++ server/api/routes.js | 4 ++ server/lib/session/index.js | 2 + server/lib/session/session.setTabletMode.js | 56 +++++++++++++++++ .../test/controllers/session/session.test.js | 31 ++++++++++ server/test/lib/session/session.test.js | 60 +++++++++++++++++++ 6 files changed, 169 insertions(+) create mode 100644 server/lib/session/session.setTabletMode.js diff --git a/server/api/controllers/session.controller.js b/server/api/controllers/session.controller.js index 6e3b02f9d0..03c595be60 100644 --- a/server/api/controllers/session.controller.js +++ b/server/api/controllers/session.controller.js @@ -11,6 +11,21 @@ module.exports = function SessionController(gladys) { res.json(session); } + /** + * @api {post} /api/v1/session/tablet_mode setTabletMode + * @apiName setTabletMode + * @apiGroup Session + */ + async function setTabletMode(req, res) { + const session = await gladys.session.setTabletMode( + req.user.id, + req.session_id, + req.body.tablet_mode, + req.body.house, + ); + res.json(session); + } + /** * @api {post} /api/v1/session/api_key createApiKey * @apiName createApiKey @@ -36,5 +51,6 @@ module.exports = function SessionController(gladys) { revoke: asyncMiddleware(revoke), createApiKey: asyncMiddleware(createApiKey), get: asyncMiddleware(get), + setTabletMode: asyncMiddleware(setTabletMode), }); }; diff --git a/server/api/routes.js b/server/api/routes.js index 9e52c5e397..90d7b9b51d 100644 --- a/server/api/routes.js +++ b/server/api/routes.js @@ -461,6 +461,10 @@ function getRoutes(gladys) { authenticated: true, controller: sessionController.revoke, }, + 'post /api/v1/session/tablet_mode': { + authenticated: true, + controller: sessionController.setTabletMode, + }, 'post /api/v1/session/api_key': { authenticated: true, controller: sessionController.createApiKey, diff --git a/server/lib/session/index.js b/server/lib/session/index.js index e9d9d3adbc..3483f9fab0 100644 --- a/server/lib/session/index.js +++ b/server/lib/session/index.js @@ -7,6 +7,7 @@ const { validateApiKey } = require('./session.validateApiKey'); const { revoke } = require('./session.revoke'); const { setTabletModeLocked } = require('./session.setTabletModeLocked'); const { unlockTabletMode } = require('./session.unlockTabletMode'); +const { setTabletMode } = require('./session.setTabletMode'); const Session = function Session(jwtSecret, cache) { this.jwtSecret = jwtSecret; @@ -22,5 +23,6 @@ Session.prototype.validateApiKey = validateApiKey; Session.prototype.revoke = revoke; Session.prototype.setTabletModeLocked = setTabletModeLocked; Session.prototype.unlockTabletMode = unlockTabletMode; +Session.prototype.setTabletMode = setTabletMode; module.exports = Session; diff --git a/server/lib/session/session.setTabletMode.js b/server/lib/session/session.setTabletMode.js new file mode 100644 index 0000000000..34abc78d58 --- /dev/null +++ b/server/lib/session/session.setTabletMode.js @@ -0,0 +1,56 @@ +const db = require('../../models'); +const { NotFoundError } = require('../../utils/coreErrors'); + +/** + * @description Set tablet model + * @param {string} userId - Id of the user. + * @param {string} sessionId - Uuid of the session. + * @param {boolean} tabletMode - Tablet mode or not. + * @param {string} houseSelector - House to set. + * @returns {Promise} Return updated session. + * @example + * setTabletMode('375223b3-71c6-4b61-a346-0a9d5baf12b4', '0a5f7305-4faf-42b3-aeb2-fbc0217c4855'); + */ +async function setTabletMode(userId, sessionId, tabletMode, houseSelector) { + const session = await db.Session.findOne({ + attributes: ['id'], + where: { + id: sessionId, + user_id: userId, + }, + }); + + if (session === null) { + throw new NotFoundError('Session not found'); + } + + let houseId = null; + + // Find house by selector if house is provided + if (houseSelector) { + const house = await db.House.findOne({ + where: { + selector: houseSelector, + }, + }); + + if (house === null) { + throw new NotFoundError('House not found'); + } + + houseId = house.id; + } + + // Set tablet mode + await session.update({ tablet_mode: tabletMode, current_house_id: houseId }); + + return { + id: session.id, + tablet_mode: tabletMode, + current_house_id: houseId, + }; +} + +module.exports = { + setTabletMode, +}; diff --git a/server/test/controllers/session/session.test.js b/server/test/controllers/session/session.test.js index bce7e71913..33a917169d 100644 --- a/server/test/controllers/session/session.test.js +++ b/server/test/controllers/session/session.test.js @@ -64,3 +64,34 @@ describe('GET /api/v1/session', () => { }); }); }); + +describe('POST /api/v1/session/tablet_mode', () => { + it('should set tablet mode to true', async () => { + await authenticatedRequest + .post('/api/v1/session/tablet_mode') + .send({ + tablet_mode: true, + house: 'test-house', + }) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + expect(res.body).to.have.property('tablet_mode', true); + expect(res.body).to.have.property('current_house_id', 'a741dfa6-24de-4b46-afc7-370772f068d5'); + }); + }); + it('should set tablet mode to false', async () => { + await authenticatedRequest + .post('/api/v1/session/tablet_mode') + .send({ + tablet_mode: false, + house: null, + }) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + expect(res.body).to.have.property('tablet_mode', false); + expect(res.body).to.have.property('current_house_id', null); + }); + }); +}); diff --git a/server/test/lib/session/session.test.js b/server/test/lib/session/session.test.js index 9bf7517a4f..e0e4f4d379 100644 --- a/server/test/lib/session/session.test.js +++ b/server/test/lib/session/session.test.js @@ -189,3 +189,63 @@ describe('session.unlockTabletMode', () => { ]); }); }); + +describe('session.setTabletMode', () => { + const cache = new Cache(); + it('should set tablet mode to true', async () => { + const session = new Session('secret', cache); + await session.setTabletMode( + '0cd30aef-9c4e-4a23-88e3-3547971296e5', + 'ada07710-5f25-4510-ac63-b002aca3bd32', + true, + 'test-house', + ); + const oneSession = await db.Session.findOne({ + where: { + id: 'ada07710-5f25-4510-ac63-b002aca3bd32', + }, + raw: true, + }); + expect(oneSession).to.have.property('tablet_mode', 1); + expect(oneSession).to.have.property('tablet_mode_locked', 0); + expect(oneSession).to.have.property('current_house_id', 'a741dfa6-24de-4b46-afc7-370772f068d5'); + }); + it('should set tablet mode to false', async () => { + const session = new Session('secret', cache); + await session.setTabletMode( + '0cd30aef-9c4e-4a23-88e3-3547971296e5', + 'ada07710-5f25-4510-ac63-b002aca3bd32', + false, + null, + ); + const oneSession = await db.Session.findOne({ + where: { + id: 'ada07710-5f25-4510-ac63-b002aca3bd32', + }, + raw: true, + }); + expect(oneSession).to.have.property('tablet_mode', 0); + expect(oneSession).to.have.property('tablet_mode_locked', 0); + expect(oneSession).to.have.property('current_house_id', null); + }); + it('should return session not found', async () => { + const session = new Session('secret', cache); + const promise = session.setTabletMode( + '0cd30aef-9c4e-4a23-88e3-3547971296e5', + 'eb260700-26d5-49ec-910f-aca90b42f585', + true, + 'test-house', + ); + await assert.isRejected(promise, 'Session not found'); + }); + it('should return house not found', async () => { + const session = new Session('secret', cache); + const promise = session.setTabletMode( + '0cd30aef-9c4e-4a23-88e3-3547971296e5', + 'ada07710-5f25-4510-ac63-b002aca3bd32', + true, + 'house not found', + ); + await assert.isRejected(promise, 'House not found'); + }); +}); From c48f72962121834c38ace06295bd52115b7ae2e5 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 20 Oct 2023 17:23:16 +0200 Subject: [PATCH 15/44] Add route to get tablet mode --- server/api/controllers/session.controller.js | 11 +++++++ server/api/routes.js | 4 +++ server/lib/session/index.js | 2 ++ server/lib/session/session.getTabletMode.js | 30 +++++++++++++++++++ .../test/controllers/session/session.test.js | 13 ++++++++ server/test/lib/session/session.test.js | 27 +++++++++++++++++ 6 files changed, 87 insertions(+) create mode 100644 server/lib/session/session.getTabletMode.js diff --git a/server/api/controllers/session.controller.js b/server/api/controllers/session.controller.js index 03c595be60..aa3c65e446 100644 --- a/server/api/controllers/session.controller.js +++ b/server/api/controllers/session.controller.js @@ -26,6 +26,16 @@ module.exports = function SessionController(gladys) { res.json(session); } + /** + * @api {get} /api/v1/session/tablet_mode getTabletMode + * @apiName getTabletMode + * @apiGroup Session + */ + async function getTabletMode(req, res) { + const session = await gladys.session.getTabletMode(req.user.id, req.session_id); + res.json(session); + } + /** * @api {post} /api/v1/session/api_key createApiKey * @apiName createApiKey @@ -52,5 +62,6 @@ module.exports = function SessionController(gladys) { createApiKey: asyncMiddleware(createApiKey), get: asyncMiddleware(get), setTabletMode: asyncMiddleware(setTabletMode), + getTabletMode: asyncMiddleware(getTabletMode), }); }; diff --git a/server/api/routes.js b/server/api/routes.js index 90d7b9b51d..c9f87b6e48 100644 --- a/server/api/routes.js +++ b/server/api/routes.js @@ -465,6 +465,10 @@ function getRoutes(gladys) { authenticated: true, controller: sessionController.setTabletMode, }, + 'get /api/v1/session/tablet_mode': { + authenticated: true, + controller: sessionController.getTabletMode, + }, 'post /api/v1/session/api_key': { authenticated: true, controller: sessionController.createApiKey, diff --git a/server/lib/session/index.js b/server/lib/session/index.js index 3483f9fab0..445bd7fc5c 100644 --- a/server/lib/session/index.js +++ b/server/lib/session/index.js @@ -7,6 +7,7 @@ const { validateApiKey } = require('./session.validateApiKey'); const { revoke } = require('./session.revoke'); const { setTabletModeLocked } = require('./session.setTabletModeLocked'); const { unlockTabletMode } = require('./session.unlockTabletMode'); +const { getTabletMode } = require('./session.getTabletMode'); const { setTabletMode } = require('./session.setTabletMode'); const Session = function Session(jwtSecret, cache) { @@ -23,6 +24,7 @@ Session.prototype.validateApiKey = validateApiKey; Session.prototype.revoke = revoke; Session.prototype.setTabletModeLocked = setTabletModeLocked; Session.prototype.unlockTabletMode = unlockTabletMode; +Session.prototype.getTabletMode = getTabletMode; Session.prototype.setTabletMode = setTabletMode; module.exports = Session; diff --git a/server/lib/session/session.getTabletMode.js b/server/lib/session/session.getTabletMode.js new file mode 100644 index 0000000000..69f89d8c29 --- /dev/null +++ b/server/lib/session/session.getTabletMode.js @@ -0,0 +1,30 @@ +const db = require('../../models'); +const { NotFoundError } = require('../../utils/coreErrors'); + +/** + * @description Get tablet model + * @param {string} userId - Id of the user. + * @param {string} sessionId - Uuid of the session. + * @returns {Promise} Return session. + * @example + * getTabletMode('375223b3-71c6-4b61-a346-0a9d5baf12b4', '0a5f7305-4faf-42b3-aeb2-fbc0217c4855'); + */ +async function getTabletMode(userId, sessionId) { + const session = await db.Session.findOne({ + attributes: ['id', 'tablet_mode', 'current_house_id'], + where: { + id: sessionId, + user_id: userId, + }, + }); + + if (session === null) { + throw new NotFoundError('Session not found'); + } + + return session.get({ plain: true }); +} + +module.exports = { + getTabletMode, +}; diff --git a/server/test/controllers/session/session.test.js b/server/test/controllers/session/session.test.js index 33a917169d..bdd5ab6967 100644 --- a/server/test/controllers/session/session.test.js +++ b/server/test/controllers/session/session.test.js @@ -95,3 +95,16 @@ describe('POST /api/v1/session/tablet_mode', () => { }); }); }); + +describe('GET /api/v1/session/tablet_mode', () => { + it('should get tablet mode', async () => { + await authenticatedRequest + .get('/api/v1/session/tablet_mode') + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + expect(res.body).to.have.property('tablet_mode', false); + expect(res.body).to.have.property('current_house_id', null); + }); + }); +}); diff --git a/server/test/lib/session/session.test.js b/server/test/lib/session/session.test.js index e0e4f4d379..1920032a75 100644 --- a/server/test/lib/session/session.test.js +++ b/server/test/lib/session/session.test.js @@ -249,3 +249,30 @@ describe('session.setTabletMode', () => { await assert.isRejected(promise, 'House not found'); }); }); + +describe('session.getTabletMode', () => { + const cache = new Cache(); + it('should get tablet mode', async () => { + const session = new Session('secret', cache); + await session.setTabletMode( + '0cd30aef-9c4e-4a23-88e3-3547971296e5', + 'ada07710-5f25-4510-ac63-b002aca3bd32', + true, + 'test-house', + ); + const oneSession = await session.getTabletMode( + '0cd30aef-9c4e-4a23-88e3-3547971296e5', + 'ada07710-5f25-4510-ac63-b002aca3bd32', + ); + expect(oneSession).to.have.property('tablet_mode', true); + expect(oneSession).to.have.property('current_house_id', 'a741dfa6-24de-4b46-afc7-370772f068d5'); + }); + it('should return session not found', async () => { + const session = new Session('secret', cache); + const promise = session.getTabletMode( + '0cd30aef-9c4e-4a23-88e3-3547971296e5', + 'eb260700-26d5-49ec-910f-aca90b42f585', + ); + await assert.isRejected(promise, 'Session not found'); + }); +}); From 12c048e3fca2bbce395c0f122d9635383f528a45 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 20 Oct 2023 18:23:19 +0200 Subject: [PATCH 16/44] Add tablet mode --- front/src/actions/main.js | 20 ++- front/src/config/i18n/fr.json | 6 + front/src/routes/dashboard/DashboardPage.jsx | 12 +- front/src/routes/dashboard/SetTabletMode.jsx | 124 +++++++++++++++++++ front/src/routes/dashboard/index.js | 28 ++++- front/src/routes/dashboard/style.css | 14 +++ 6 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 front/src/routes/dashboard/SetTabletMode.jsx diff --git a/front/src/actions/main.js b/front/src/actions/main.js index 940ca6d32f..d412efae33 100644 --- a/front/src/actions/main.js +++ b/front/src/actions/main.js @@ -41,6 +41,16 @@ function createActions(store) { const returnUrl = window.location.pathname + window.location.search; route(`/login?return_url=${encodeURIComponent(returnUrl)}`); }, + async refreshTabletMode(state) { + try { + const currentSession = await state.httpClient.get('/api/v1/session/tablet_mode'); + store.setState({ + tabletMode: currentSession.tablet_mode + }); + } catch (e) { + console.error(e); + } + }, async checkSession(state) { if (isUrlInArray(state.currentUrl, OPEN_PAGES)) { return null; @@ -50,7 +60,11 @@ function createActions(store) { if (!state.session.isConnected()) { actions.redirectToLogin(); } - const tasks = [state.httpClient.get('/api/v1/me'), actionsProfilePicture.loadProfilePicture(state)]; + const tasks = [ + state.httpClient.get('/api/v1/me'), + actionsProfilePicture.loadProfilePicture(state), + actions.refreshTabletMode(state) + ]; const [user] = await Promise.all(tasks); store.setState({ user @@ -69,7 +83,9 @@ function createActions(store) { const error = get(e, 'response.data.error'); const gatewayErrorMessage = get(e, 'response.data.error_message'); const errorMessageOtherFormat = get(e, 'response.data.message'); - if (status === 401) { + if (status === 401 && errorMessageOtherFormat === 'TABLET_IS_LOCKED') { + route('/locked'); + } else if (status === 401) { state.session.reset(); actions.redirectToLogin(); } else if (error === 'GATEWAY_USER_NOT_LINKED') { diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 6b30bb0550..1b78d37823 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -207,6 +207,12 @@ "editDashboardDeleteButton": "Supprimer", "editDashboardSaveButton": "Sauvegarder", "editDashboardDeleteText": "Êtes-vous sûr de vouloir supprimer ce tableau de bord ?", + "toggleDefineTabletMode": "Mode tablette", + "tabletMode": { + "description": "Le mode tablette sert à la fonctionnalité alarme. Si vous armez l'alarme, toutes les tablettes de la maison seront verrouillées et afficherons un clavier virtuel pour désactiver l'alarme.", + "houseLabel": "Maison", + "tabletModeDisabled": "Mode tablette désactivé" + }, "enableFullScreen": "Plein écran", "disableFullScreen": "Quitter plein écran", "editDashboardTitle": "Editer le tableau de bord", diff --git a/front/src/routes/dashboard/DashboardPage.jsx b/front/src/routes/dashboard/DashboardPage.jsx index 4089c5c49c..94c92e2747 100644 --- a/front/src/routes/dashboard/DashboardPage.jsx +++ b/front/src/routes/dashboard/DashboardPage.jsx @@ -3,6 +3,7 @@ import { Link } from 'preact-router/match'; import cx from 'classnames'; import BoxColumns from './BoxColumns'; import EmptyState from './EmptyState'; +import SetTabletMode from './SetTabletMode'; import style from './style.css'; @@ -41,6 +42,12 @@ const DashboardPage = ({ children, ...props }) => (
+ {!props.dashboardNotConfigured && props.browserFullScreenCompatible && !props.hideExitFullScreenButton && ( @@ -63,12 +70,15 @@ const DashboardPage = ({ children, ...props }) => ( )}
- {props.gatewayInstanceNotFound && (
)} + {props.dashboardNotConfigured && } {!props.dashboardNotConfigured && } diff --git a/front/src/routes/dashboard/SetTabletMode.jsx b/front/src/routes/dashboard/SetTabletMode.jsx new file mode 100644 index 0000000000..0d7ea8d760 --- /dev/null +++ b/front/src/routes/dashboard/SetTabletMode.jsx @@ -0,0 +1,124 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import { Text } from 'preact-i18n'; +import cx from 'classnames'; +import mainActions from '../../actions/main'; +import style from './style.css'; + +class SetTabletMode extends Component { + getHouses = async () => { + try { + const houses = await this.props.httpClient.get('/api/v1/house'); + await this.setState({ + houses + }); + } catch (e) { + console.error(e); + } + }; + + getTabletMode = async () => { + try { + const currentSession = await this.props.httpClient.get('/api/v1/session/tablet_mode'); + let selectedHouse = null; + if (this.state.houses && currentSession.current_house_id) { + const houseFound = this.state.houses.find(h => h.id === currentSession.current_house_id); + selectedHouse = houseFound ? houseFound.selector : null; + } + await this.setState({ + currentSession, + selectedHouse, + selectedTabletMode: currentSession.tablet_mode + }); + } catch (e) { + console.error(e); + } + }; + + saveTabletMode = async () => { + await this.setState({ + loading: true + }); + try { + await this.props.httpClient.post('/api/v1/session/tablet_mode', { + tablet_mode: this.state.selectedHouse !== null, + house: this.state.selectedHouse + }); + await this.props.refreshTabletMode(); + this.props.toggleDefineTabletMode(); + } catch (e) { + console.error(e); + } + await this.setState({ + loading: false + }); + }; + + refreshData = async () => { + await this.setState({ + loading: true + }); + await this.getHouses(); + await this.getTabletMode(); + await this.setState({ + loading: false + }); + }; + + onHouseChange = e => { + this.setState({ selectedHouse: e.target.value || null }); + }; + + constructor(props) { + super(props); + this.state = { + houses: [] + }; + } + + componentDidMount() { + this.refreshData(); + } + + render({ defineTabletModeOpened }, { houses, selectedHouse, loading }) { + return ( +
+
+
+
+

+ +

+
+
+ +
+ +
+
+ +
+
+
+
+ ); + } +} + +export default connect('httpClient,user', mainActions)(SetTabletMode); diff --git a/front/src/routes/dashboard/index.js b/front/src/routes/dashboard/index.js index f8f72fc9bc..302e8b3e85 100644 --- a/front/src/routes/dashboard/index.js +++ b/front/src/routes/dashboard/index.js @@ -5,6 +5,7 @@ import { route } from 'preact-router'; import DashboardPage from './DashboardPage'; import GatewayAccountExpired from '../../components/gateway/GatewayAccountExpired'; import actions from '../../actions/dashboard'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../server/utils/constants'; import get from 'get-value'; class Dashboard extends Component { @@ -14,6 +15,12 @@ class Dashboard extends Component { }); }; + toggleDefineTabletMode = () => { + this.setState(prevState => { + return { ...prevState, defineTabletModeOpened: !this.state.defineTabletModeOpened }; + }); + }; + closeDashboardDropdown = () => { if (this.state.dashboardDropdownOpened) { this.setState({ @@ -148,11 +155,20 @@ class Dashboard extends Component { this.props.setFullScreen(isFullScreen); }; + alarmArmed = () => { + if (this.props.tabletMode) { + route('/locked'); + } + }; + + alarmArming = () => {}; + constructor(props) { super(props); this.props = props; this.state = { dashboardDropdownOpened: false, + defineTabletModeOpened: false, dashboardEditMode: false, showReorderDashboard: false, browserFullScreenCompatible: this.isBrowserFullScreenCompatible(), @@ -168,6 +184,8 @@ class Dashboard extends Component { document.addEventListener('webkitfullscreenchange', this.onFullScreenChange, false); document.addEventListener('mozfullscreenchange', this.onFullScreenChange, false); document.addEventListener('click', this.closeDashboardDropdown, true); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.ALARM.ARMED, this.alarmArmed); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.ALARM.ARMING, this.alarmArming); this.checkIfFullScreenParameterIsHere(); } @@ -182,12 +200,15 @@ class Dashboard extends Component { document.removeEventListener('webkitfullscreenchange', this.onFullScreenChange, false); document.removeEventListener('mozfullscreenchange', this.onFullScreenChange, false); document.removeEventListener('click', this.closeDashboardDropdown, true); + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.ALARM.ARMED, this.alarmArmed); + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.ALARM.ARMING, this.alarmArming); } render( props, { dashboardDropdownOpened, + defineTabletModeOpened, dashboards, currentDashboard, dashboardEditMode, @@ -211,6 +232,7 @@ class Dashboard extends Component { @@ -230,4 +253,7 @@ class Dashboard extends Component { } } -export default connect('user,fullScreen,currentUrl,httpClient,gatewayAccountExpired', actions)(Dashboard); +export default connect( + 'user,session,fullScreen,currentUrl,httpClient,gatewayAccountExpired,tabletMode', + actions +)(Dashboard); diff --git a/front/src/routes/dashboard/style.css b/front/src/routes/dashboard/style.css index eb120b0d5d..a1ef4a1f4f 100644 --- a/front/src/routes/dashboard/style.css +++ b/front/src/routes/dashboard/style.css @@ -75,3 +75,17 @@ display: none; } } + +.tabletModeDiv { + opacity: 0; + max-height: 0; + visibility: hidden; + transition: opacity 0.3s ease, max-height 0.3s ease; + padding: 0rem; +} + +.tabletModeDivOpen { + visibility: visible; + opacity: 1; + max-height: 15rem; +} From 5852590e34784160785aa8e92f18d58ceb7c596d Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 23 Oct 2023 12:00:45 +0200 Subject: [PATCH 17/44] Lock/unlock tablet before websocket event --- server/lib/house/house.arm.js | 4 ++-- server/lib/house/house.disarm.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/lib/house/house.arm.js b/server/lib/house/house.arm.js index cca3cda569..31c5da6178 100644 --- a/server/lib/house/house.arm.js +++ b/server/lib/house/house.arm.js @@ -37,6 +37,8 @@ async function arm(selector) { setTimeout(async () => { // Update database await house.update({ alarm_mode: ALARM_MODES.ARMED }); + // Lock all tablets in this house + await this.session.setTabletModeLocked(house.id); // Check scene triggers this.event.emit(EVENTS.TRIGGERS.CHECK, { type: EVENTS.ALARM.ARM, @@ -49,8 +51,6 @@ async function arm(selector) { house: selector, }, }); - // Lock all tablets in this house - await this.session.setTabletModeLocked(house.id); }, house.alarm_delay_before_arming * 1000); } diff --git a/server/lib/house/house.disarm.js b/server/lib/house/house.disarm.js index 8d508947b3..3971d5ad49 100644 --- a/server/lib/house/house.disarm.js +++ b/server/lib/house/house.disarm.js @@ -27,6 +27,8 @@ async function disarm(selector) { } // Update database await house.update({ alarm_mode: ALARM_MODES.DISARMED }); + // Unlock all tablets in this house + await this.session.unlockTabletMode(house.id); // Check scene triggers this.event.emit(EVENTS.TRIGGERS.CHECK, { type: EVENTS.ALARM.DISARM, @@ -39,8 +41,6 @@ async function disarm(selector) { house: selector, }, }); - // Unlock all tablets in this house - await this.session.unlockTabletMode(house.id); return house.get({ plain: true }); } From 66478afc226b0455adeb3004b53914c5c61144da Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 23 Oct 2023 12:01:44 +0200 Subject: [PATCH 18/44] Validate token with alarm:write for websockets --- server/api/websockets/index.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/server/api/websockets/index.js b/server/api/websockets/index.js index 6493d327ef..84eae9d425 100644 --- a/server/api/websockets/index.js +++ b/server/api/websockets/index.js @@ -118,10 +118,13 @@ function init() { case WEBSOCKET_MESSAGE_TYPES.AUTHENTICATION.REQUEST: try { // we validate the token - const payload = this.gladys.session.validateAccessToken( - parsedMessage.payload.accessToken, - 'dashboard:write', - ); + let payload; + try { + payload = this.gladys.session.validateAccessToken(parsedMessage.payload.accessToken, 'dashboard:write'); + } catch (e) { + logger.debug(`Cannot validate websocket token with dashboard:write (${e}), trying with alarm:write`); + payload = this.gladys.session.validateAccessToken(parsedMessage.payload.accessToken, 'alarm:write'); + } user = await this.gladys.user.getById(payload.user_id); authenticated = true; this.userConnected(user, ws); From 2b162f1b24c507d4bb6cf43ab3702f7e66e41bc8 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 23 Oct 2023 12:01:53 +0200 Subject: [PATCH 19/44] Reduce rate limit --- server/api/middlewares/rateLimitMiddleware.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/api/middlewares/rateLimitMiddleware.js b/server/api/middlewares/rateLimitMiddleware.js index a854dfb99c..0b13e49cb3 100644 --- a/server/api/middlewares/rateLimitMiddleware.js +++ b/server/api/middlewares/rateLimitMiddleware.js @@ -2,8 +2,8 @@ const rateLimit = require('express-rate-limit'); // @ts-ignore const limiter = rateLimit({ - windowMs: 30 * 60 * 1000, // 30 minutes - max: 20, // limit each IP to 20 requests per windowMs + windowMs: 5 * 60 * 1000, // 5 minutes + max: 100, // limit each IP to 100 requests }); module.exports = limiter; From 12a3a54c50f2185c2c74cd320021bd90b8b4a451 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 23 Oct 2023 15:04:48 +0200 Subject: [PATCH 20/44] Unlock alarm --- front/src/routes/dashboard/SetTabletMode.jsx | 3 +- front/src/routes/locked/index.js | 55 +++++++++++++++++++- front/src/utils/HttpClient.js | 17 ++++-- front/src/utils/Session.js | 12 +++++ 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/front/src/routes/dashboard/SetTabletMode.jsx b/front/src/routes/dashboard/SetTabletMode.jsx index 0d7ea8d760..bf845becf5 100644 --- a/front/src/routes/dashboard/SetTabletMode.jsx +++ b/front/src/routes/dashboard/SetTabletMode.jsx @@ -45,6 +45,7 @@ class SetTabletMode extends Component { house: this.state.selectedHouse }); await this.props.refreshTabletMode(); + this.props.session.setTabletModeCurrentHouseSelector(this.state.selectedHouse); this.props.toggleDefineTabletMode(); } catch (e) { console.error(e); @@ -121,4 +122,4 @@ class SetTabletMode extends Component { } } -export default connect('httpClient,user', mainActions)(SetTabletMode); +export default connect('httpClient,user,session', mainActions)(SetTabletMode); diff --git a/front/src/routes/locked/index.js b/front/src/routes/locked/index.js index ea771d3139..3ab9e91d44 100644 --- a/front/src/routes/locked/index.js +++ b/front/src/routes/locked/index.js @@ -2,7 +2,9 @@ import { Component } from 'preact'; import { connect } from 'unistore/preact'; import { Text, Localizer } from 'preact-i18n'; import cx from 'classnames'; +import { route } from 'preact-router'; import style from './style.css'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../server/utils/constants'; const BUTTON_ARRAY = [ [1, 2, 3], @@ -56,6 +58,22 @@ class Locked extends Component { return { ...prevState, currentCode: prevState.currentCode + letter }; }); }; + init = async () => { + try { + // We make a dumb request just to verify if our token is valid + const result = await this.props.httpClient.post('/api/v1/access_token', { + refresh_token: this.props.session.getRefreshToken(), + scope: ['dashboard:write'] + }); + console.log(result); + // if this resolves, we redirect to dashboard + route('/dashboard'); + } catch (e) { + console.log(e); + this.props.httpClient.setApiScopes(['alarm:write']); + this.props.httpClient.refreshAccessToken(); + } + }; constructor(props) { super(props); this.props = props; @@ -63,7 +81,37 @@ class Locked extends Component { currentCode: '' }; } - componentDidMount() {} + disarmed = async event => { + try { + const houseSelector = this.props.session.getTabletModeCurrentHouseSelector(); + // If the same house was disarmed, redirect to dashboard + if (event.house === houseSelector) { + this.props.httpClient.resetApiScopes(); + await this.props.httpClient.refreshAccessToken(); + route('/dashboard'); + } + } catch (e) { + console.error(e); + } + }; + validateCode = async e => { + e.preventDefault(); + try { + const houseSelector = this.props.session.getTabletModeCurrentHouseSelector(); + await this.props.httpClient.post(`/api/v1/house/${houseSelector}/disarm_with_code`, { + code: this.state.currentCode + }); + } catch (e) { + console.error(e); + } + }; + componentDidMount() { + this.init(); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.ALARM.DISARMED, this.disarmed); + } + componentWillUnmount() { + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.ALARM.DISARMED, this.disarmed); + } render({}, { currentCode }) { return (
@@ -96,6 +144,9 @@ class Locked extends Component { typeLetter={this.typeLetter} clearPreviousLetter={this.clearPreviousLetter} /> +
@@ -106,4 +157,4 @@ class Locked extends Component { } } -export default connect('', {})(Locked); +export default connect('httpClient,session', {})(Locked); diff --git a/front/src/utils/HttpClient.js b/front/src/utils/HttpClient.js index e932c92dca..48d1aa5ff6 100644 --- a/front/src/utils/HttpClient.js +++ b/front/src/utils/HttpClient.js @@ -2,11 +2,13 @@ import axios from 'axios'; import config from '../config'; const MAX_RETRY = 3; +const DEFAULT_API_SCOPES = ['dashboard:read', 'dashboard:write']; export class HttpClient { - constructor(session) { + constructor(session, apiScopes = DEFAULT_API_SCOPES) { this.session = session; this.localApiUrl = config.localApiUrl || window.location.origin; + this.apiScopes = apiScopes; } getAxiosHeaders() { @@ -17,13 +19,22 @@ export class HttpClient { return headers; } + setApiScopes(scopes) { + this.apiScopes = scopes; + } + + resetApiScopes() { + this.apiScopes = DEFAULT_API_SCOPES; + } + async refreshAccessToken() { const { data } = await axios({ baseURL: this.localApiUrl, url: '/api/v1/access_token', method: 'post', data: { - refresh_token: this.session.getRefreshToken() + refresh_token: this.session.getRefreshToken(), + scope: this.apiScopes } }); this.session.setAccessToken(data.access_token); @@ -47,7 +58,7 @@ export class HttpClient { }); return data; } catch (e) { - if (e.response && e.response.status === 401) { + if (e.response && e.response.status === 401 && e.response.data.message !== 'TABLET_IS_LOCKED') { await this.refreshAccessToken(); return this.executeQuery(method, url, query, body, retryCount + 1); } diff --git a/front/src/utils/Session.js b/front/src/utils/Session.js index fc8f8fb39e..73b25c4254 100644 --- a/front/src/utils/Session.js +++ b/front/src/utils/Session.js @@ -84,6 +84,18 @@ class Session { return this.user; } + setTabletModeCurrentHouseSelector(houseSelector) { + if (houseSelector) { + localStorage.setItem('current_house_selector', houseSelector); + } else { + localStorage.removeItem('current_house_selector'); + } + } + + getTabletModeCurrentHouseSelector() { + return localStorage.getItem('current_house_selector'); + } + getRefreshToken() { if (this.user) { return this.user.refresh_token; From 1eb33c556d393138299da2ec4a1f2f4bfaeb0c5c Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 23 Oct 2023 15:19:02 +0200 Subject: [PATCH 21/44] Fix server tests --- server/test/lib/house/house.arm.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/test/lib/house/house.arm.test.js b/server/test/lib/house/house.arm.test.js index 9c06f94809..0a89e6850d 100644 --- a/server/test/lib/house/house.arm.test.js +++ b/server/test/lib/house/house.arm.test.js @@ -14,7 +14,10 @@ const event = { }; describe('house.arm', () => { - const house = new House(event); + const session = { + setTabletModeLocked: fake.resolves(null), + }; + const house = new House(event, {}, session); beforeEach(async () => { await house.update('test-house', { alarm_delay_before_arming: 0, From acf3ed176c94e8628ef132cd876b9565bdab800a Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 23 Oct 2023 16:39:17 +0200 Subject: [PATCH 22/44] Should be possible to cancel arming alarm --- server/lib/house/house.arm.js | 5 ++++- server/lib/house/house.disarm.js | 6 ++++++ server/lib/house/index.js | 1 + server/test/lib/house/house.disarm.test.js | 11 +++++++++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/server/lib/house/house.arm.js b/server/lib/house/house.arm.js index 31c5da6178..db408ac10d 100644 --- a/server/lib/house/house.arm.js +++ b/server/lib/house/house.arm.js @@ -34,7 +34,7 @@ async function arm(selector) { }, }); // Wait the delay before arming - setTimeout(async () => { + const currentTimeout = setTimeout(async () => { // Update database await house.update({ alarm_mode: ALARM_MODES.ARMED }); // Lock all tablets in this house @@ -52,6 +52,9 @@ async function arm(selector) { }, }); }, house.alarm_delay_before_arming * 1000); + + // store the timeout so we can cancel it if needed + this.armingHouseTimeout.set(selector, currentTimeout); } module.exports = { diff --git a/server/lib/house/house.disarm.js b/server/lib/house/house.disarm.js index 3971d5ad49..af21b1165e 100644 --- a/server/lib/house/house.disarm.js +++ b/server/lib/house/house.disarm.js @@ -12,6 +12,12 @@ const { NotFoundError, ConflictError } = require('../../utils/coreErrors'); * const mainHouse = await gladys.house.disarm('main-house'); */ async function disarm(selector) { + // In case there is a timeout to arm this house, we clear it + clearTimeout(this.armingHouseTimeout.get(selector)); + if (this.armingHouseTimeout.get(selector)) { + this.armingHouseTimeout.delete(selector); + } + // Get the house from DB const house = await db.House.findOne({ where: { selector, diff --git a/server/lib/house/index.js b/server/lib/house/index.js index ac620e86d0..f721451662 100644 --- a/server/lib/house/index.js +++ b/server/lib/house/index.js @@ -18,6 +18,7 @@ const House = function House(event, stateManager, session) { this.event = event; this.stateManager = stateManager; this.session = session; + this.armingHouseTimeout = new Map(); }; House.prototype.arm = arm; diff --git a/server/test/lib/house/house.disarm.test.js b/server/test/lib/house/house.disarm.test.js index 7a1f4071f0..0a3b49374a 100644 --- a/server/test/lib/house/house.disarm.test.js +++ b/server/test/lib/house/house.disarm.test.js @@ -48,6 +48,17 @@ describe('house.disarm', () => { }, ]); }); + it('should arm with timer then disarm a house and have no timeout left', async () => { + await house.update('test-house', { + alarm_delay_before_arming: 5, + alarm_mode: ALARM_MODES.DISARMED, + }); + await house.arm('test-house'); + const promise = house.disarm('test-house'); + await assertChai.isRejected(promise); + // Timeout should be deleted + expect(house.armingHouseTimeout.size).to.equal(0); + }); it('should return house not found', async () => { const promise = house.disarm('house-not-found'); return assertChai.isRejected(promise, 'House not found'); From 25201f5bc177508cc5bc8d49ec1607c033fbb27c Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 23 Oct 2023 16:39:29 +0200 Subject: [PATCH 23/44] Improve design of widget --- front/src/components/boxs/alarm/Alarm.jsx | 133 +++++++++++------- front/src/components/boxs/alarm/Coutdown.jsx | 42 ++++++ front/src/components/boxs/alarm/countdown.css | 17 +++ front/src/components/boxs/alarm/style.css | 6 +- front/src/components/house/EditHouse.jsx | 3 + front/src/config/i18n/fr.json | 4 +- 6 files changed, 154 insertions(+), 51 deletions(-) create mode 100644 front/src/components/boxs/alarm/Coutdown.jsx create mode 100644 front/src/components/boxs/alarm/countdown.css diff --git a/front/src/components/boxs/alarm/Alarm.jsx b/front/src/components/boxs/alarm/Alarm.jsx index aaebee128c..0da452c7c8 100644 --- a/front/src/components/boxs/alarm/Alarm.jsx +++ b/front/src/components/boxs/alarm/Alarm.jsx @@ -3,6 +3,7 @@ import { connect } from 'unistore/preact'; import cx from 'classnames'; import { Text } from 'preact-i18n'; import { ALARM_MODES, WEBSOCKET_MESSAGE_TYPES } from '../../../../../server/utils/constants'; +import Countdown from './Coutdown'; import style from './style.css'; @@ -13,6 +14,11 @@ class AlarmComponent extends Component { await this.setState({ arming: true }); }; + cancelArming = async () => { + await this.disarm(); + await this.getHouse(); + }; + getHouse = async () => { await this.setState({ loading: true }); try { @@ -74,6 +80,7 @@ class AlarmComponent extends Component { render(props, { house, loading, arming }) { const armingDisabled = (house && house.alarm_mode === ALARM_MODES.ARMED) || arming; const partialArmDisabled = (house && house.alarm_mode === ALARM_MODES.PARTIALLY_ARMED) || arming; + const isCurrentlyArmingWithCoutdown = arming && house.alarm_delay_before_arming > 0; return (
@@ -93,59 +100,87 @@ class AlarmComponent extends Component { .

)} - {arming && ( + {isCurrentlyArmingWithCoutdown && (

- + + +

)} -
-
- -
-
- + {!isCurrentlyArmingWithCoutdown && ( +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
-
-
-
- -
-
- -
-
+ )}
diff --git a/front/src/components/boxs/alarm/Coutdown.jsx b/front/src/components/boxs/alarm/Coutdown.jsx new file mode 100644 index 0000000000..62fcb48243 --- /dev/null +++ b/front/src/components/boxs/alarm/Coutdown.jsx @@ -0,0 +1,42 @@ +import { h, Component } from 'preact'; +import style from './countdown.css'; + +class Countdown extends Component { + constructor(props) { + super(props); + this.state = { + seconds: props.seconds + }; + } + + updateCountdown() { + if (this.state.seconds > 0) { + this.setState(prevState => ({ seconds: prevState.seconds - 1, updated: true })); + + // Clear the "updated" class after the animation duration (0.5 seconds) + setTimeout(() => { + this.setState({ updated: false }); + }, 500); + } + } + + componentDidMount() { + this.countdownInterval = setInterval(this.updateCountdown.bind(this), 1000); + } + + componentWillUnmount() { + clearInterval(this.countdownInterval); + } + + render() { + return ( +
+
+ {this.state.seconds} +
+
+ ); + } +} + +export default Countdown; diff --git a/front/src/components/boxs/alarm/countdown.css b/front/src/components/boxs/alarm/countdown.css new file mode 100644 index 0000000000..c0e8a5443b --- /dev/null +++ b/front/src/components/boxs/alarm/countdown.css @@ -0,0 +1,17 @@ +.countdown { + font-size: 5em; + color: #333; + font-weight: bold; + text-align: center; +} + +.countdownTimer { + display: inline-block; + padding: 10px; + transition: transform 0.5s, opacity 0.5s; +} + +.countdownTimer.updated { + transform: scale(0.9); + opacity: 0.7; +} diff --git a/front/src/components/boxs/alarm/style.css b/front/src/components/boxs/alarm/style.css index bce7192a01..69f015f1c7 100644 --- a/front/src/components/boxs/alarm/style.css +++ b/front/src/components/boxs/alarm/style.css @@ -1,4 +1,8 @@ .alarmActionButton { - height: 5rem; + height: 6rem; line-height: 16px; } + +.alarmActionIcon { + font-size: 30px !important; +} diff --git a/front/src/components/house/EditHouse.jsx b/front/src/components/house/EditHouse.jsx index d6ded09d26..5ed0d4c17b 100644 --- a/front/src/components/house/EditHouse.jsx +++ b/front/src/components/house/EditHouse.jsx @@ -137,6 +137,9 @@ const EditHouse = ({ children, ...props }) => ( value={props.house.alarm_delay_before_arming} onChange={props.updateHouseDelayBeforeArming} > + diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index ab476bca86..70e3dfdacb 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -184,6 +184,7 @@ "alarmCodePlaceholder": "Entrez un code à 6 chiffres", "alarmDelayBeforeArmingLabel": "Délai avant armement de l'alarme", "alarmDelays": { + "0": "Pas de délai", "5": "5 secondes", "10": "10 secondes", "15": "15 secondes", @@ -373,7 +374,8 @@ "editBoxNamePlaceholder": "Entrez le nom du widget", "editHouseLabel": "Sélectionnez la maison qui est concernée pour l'activation/désactivation de l'alarme.", "alarmStatusText": "Votre maison est ", - "alarmArming": "Votre maison est entrain d'être armée... Vous avez {{count}} secondes pour quitter la maison." + "alarmArming": "Votre maison est entrain d'être armée...", + "cancelAlarmArming": "Annuler" } } }, From 2845127c5fc88a35bd95b2cef9f144a97642ca8b Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 23 Oct 2023 16:45:59 +0200 Subject: [PATCH 24/44] Add alarm.arming trigger --- server/lib/house/house.arm.js | 5 +++ server/lib/scene/scene.triggers.js | 1 + server/test/lib/house/house.arm.test.js | 11 ++++-- .../triggers/scene.trigger.alarmMode.test.js | 34 +++++++++++++++++++ server/utils/constants.js | 1 + 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/server/lib/house/house.arm.js b/server/lib/house/house.arm.js index db408ac10d..90062da07d 100644 --- a/server/lib/house/house.arm.js +++ b/server/lib/house/house.arm.js @@ -33,6 +33,11 @@ async function arm(selector) { house: selector, }, }); + // Check trigger scene is arming + this.event.emit(EVENTS.TRIGGERS.CHECK, { + type: EVENTS.ALARM.ARMING, + house: selector, + }); // Wait the delay before arming const currentTimeout = setTimeout(async () => { // Update database diff --git a/server/lib/scene/scene.triggers.js b/server/lib/scene/scene.triggers.js index 06cd515548..55207866f2 100644 --- a/server/lib/scene/scene.triggers.js +++ b/server/lib/scene/scene.triggers.js @@ -26,6 +26,7 @@ const triggersFunc = { [EVENTS.AREA.USER_ENTERED]: (event, trigger) => event.user === trigger.user && event.area === trigger.area, [EVENTS.AREA.USER_LEFT]: (event, trigger) => event.user === trigger.user && event.area === trigger.area, [EVENTS.ALARM.ARM]: (event, trigger) => event.house === trigger.house, + [EVENTS.ALARM.ARMING]: (event, trigger) => event.house === trigger.house, [EVENTS.ALARM.DISARM]: (event, trigger) => event.house === trigger.house, [EVENTS.ALARM.PARTIAL_ARM]: (event, trigger) => event.house === trigger.house, [EVENTS.ALARM.PANIC]: (event, trigger) => event.house === trigger.house, diff --git a/server/test/lib/house/house.arm.test.js b/server/test/lib/house/house.arm.test.js index 0a89e6850d..875bb9c7d1 100644 --- a/server/test/lib/house/house.arm.test.js +++ b/server/test/lib/house/house.arm.test.js @@ -30,7 +30,7 @@ describe('house.arm', () => { it('should arm a house', async () => { await house.arm('test-house'); await Promise.delay(5); - assert.calledThrice(event.emit); + assert.callCount(event.emit, 4); expect(event.emit.firstCall.args).to.deep.equal([ EVENTS.WEBSOCKET.SEND_ALL, { @@ -43,11 +43,18 @@ describe('house.arm', () => { expect(event.emit.secondCall.args).to.deep.equal([ EVENTS.TRIGGERS.CHECK, { - type: EVENTS.ALARM.ARM, + type: EVENTS.ALARM.ARMING, house: 'test-house', }, ]); expect(event.emit.thirdCall.args).to.deep.equal([ + EVENTS.TRIGGERS.CHECK, + { + type: EVENTS.ALARM.ARM, + house: 'test-house', + }, + ]); + expect(event.emit.args[3]).to.deep.equal([ EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.ALARM.ARMED, diff --git a/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js b/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js index 2265f9742c..283bdf61f7 100644 --- a/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js +++ b/server/test/lib/scene/triggers/scene.trigger.alarmMode.test.js @@ -79,6 +79,40 @@ describe('Scene.triggers.alarmMode', () => { }); }); }); + it('should execute scene with alarm.arming trigger', async () => { + sceneManager.addScene({ + selector: 'my-scene', + active: true, + actions: [ + [ + { + type: ACTIONS.LIGHT.TURN_OFF, + devices: ['light-1'], + }, + ], + ], + triggers: [ + { + type: EVENTS.ALARM.ARMING, + house: 'house-1', + }, + ], + }); + sceneManager.checkTrigger({ + type: EVENTS.ALARM.ARMING, + house: 'house-1', + }); + return new Promise((resolve, reject) => { + sceneManager.queue.start(() => { + try { + assert.calledOnce(device.setValue); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); it('should execute scene with alarm.disarm trigger', async () => { sceneManager.addScene({ selector: 'my-scene', diff --git a/server/utils/constants.js b/server/utils/constants.js index 9d44dcc1fe..fc0ed065ef 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -103,6 +103,7 @@ const SYSTEM_VARIABLE_NAMES = { const EVENTS = { ALARM: { ARM: 'alarm.arm', + ARMING: 'alarm.arming', DISARM: 'alarm.disarm', PARTIAL_ARM: 'alarm.partial-arm', PANIC: 'alarm.panic', From 51a2048bf6ee099b794d03f0bccec030e9a9e0cb Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 23 Oct 2023 16:51:32 +0200 Subject: [PATCH 25/44] Add alarm arming trigger in UI --- front/src/config/i18n/fr.json | 2 ++ front/src/routes/scene/edit-scene/TriggerCard.jsx | 13 ++++++++++--- .../edit-scene/triggers/ChooseTriggerTypeCard.jsx | 1 + 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 70e3dfdacb..0a34dda9db 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -1623,6 +1623,7 @@ }, "alarm": { "arm": "L'alarme est armée", + "arming": "L'alarme est en cours d'armement", "disarm": "L'alarme est désarmée", "partial-arm": "L'alarme est armée partiellement", "panic": "L'alarme est en mode panique" @@ -1717,6 +1718,7 @@ "alarmMode": { "alarm": { "arm": "Ce déclencheur se lancera quand la maison sélectionnée est armée.", + "arming": "Ce déclencheur se lancera quand la maison sélectionnée va être armée.", "disarm": "Ce déclencheur se lancera quand la maison sélectionnée est désarmée.", "partial-arm": "Ce déclencheur se lancera quand la maison sélectionnée est armée partiellement.", "panic": "Ce déclencheur se lancera quand la maison sélectionnée est en mode panique." diff --git a/front/src/routes/scene/edit-scene/TriggerCard.jsx b/front/src/routes/scene/edit-scene/TriggerCard.jsx index fd8eb7db1f..e7c69c0ddd 100644 --- a/front/src/routes/scene/edit-scene/TriggerCard.jsx +++ b/front/src/routes/scene/edit-scene/TriggerCard.jsx @@ -27,12 +27,21 @@ const TRIGGER_ICON = { [EVENTS.AREA.USER_LEFT]: 'fe-compass', [EVENTS.CALENDAR.EVENT_IS_COMING]: 'fe-calendar', [EVENTS.ALARM.ARM]: 'fe-bell', + [EVENTS.ALARM.ARMING]: 'fe-clock', [EVENTS.ALARM.PARTIAL_ARM]: 'fe-bell', [EVENTS.ALARM.DISARM]: 'fe-bell-off', [EVENTS.ALARM.PANIC]: 'fe-alert-triangle', [EVENTS.SYSTEM.START]: 'fe-activity' }; +const ALARM_TRIGGERS = [ + EVENTS.ALARM.ARM, + EVENTS.ALARM.ARMING, + EVENTS.ALARM.DISARM, + EVENTS.ALARM.PARTIAL_ARM, + EVENTS.ALARM.PANIC +]; + const deleteTriggerFromList = (deleteTrigger, index) => () => { deleteTrigger(index); }; @@ -141,9 +150,7 @@ const TriggerCard = ({ children, ...props }) => ( setVariablesTrigger={props.setVariablesTrigger} /> )} - {[EVENTS.ALARM.ARM, EVENTS.ALARM.DISARM, EVENTS.ALARM.PARTIAL_ARM, EVENTS.ALARM.PANIC].includes( - props.trigger.type - ) && ( + {ALARM_TRIGGERS.includes(props.trigger.type) && ( Date: Mon, 23 Oct 2023 17:34:19 +0200 Subject: [PATCH 26/44] Hide tablet mode on Gladys Plus + improve UX tablet mode --- front/src/config/i18n/fr.json | 1 + front/src/routes/dashboard/DashboardPage.jsx | 14 ++++++++------ front/src/routes/dashboard/SetTabletMode.jsx | 5 ++++- front/src/routes/dashboard/index.js | 1 + 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 0a34dda9db..1a9b93e3e9 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -211,6 +211,7 @@ "toggleDefineTabletMode": "Mode tablette", "tabletMode": { "description": "Le mode tablette sert à la fonctionnalité alarme. Si vous armez l'alarme, toutes les tablettes de la maison seront verrouillées et afficherons un clavier virtuel pour désactiver l'alarme.", + "fullScreenForce": "Si vous voulez forcer une tablette à rester en mode plein écran, vous pouvez ajouter ?fullscreen=force à l'URL.", "houseLabel": "Maison", "tabletModeDisabled": "Mode tablette désactivé" }, diff --git a/front/src/routes/dashboard/DashboardPage.jsx b/front/src/routes/dashboard/DashboardPage.jsx index 94c92e2747..86f0bc8c77 100644 --- a/front/src/routes/dashboard/DashboardPage.jsx +++ b/front/src/routes/dashboard/DashboardPage.jsx @@ -42,12 +42,14 @@ const DashboardPage = ({ children, ...props }) => (
- + {!props.isGladysPlus && ( + + )} {!props.dashboardNotConfigured && props.browserFullScreenCompatible && !props.hideExitFullScreenButton && ( diff --git a/front/src/routes/dashboard/SetTabletMode.jsx b/front/src/routes/dashboard/SetTabletMode.jsx index bf845becf5..4ac80e206d 100644 --- a/front/src/routes/dashboard/SetTabletMode.jsx +++ b/front/src/routes/dashboard/SetTabletMode.jsx @@ -1,6 +1,6 @@ import { Component } from 'preact'; import { connect } from 'unistore/preact'; -import { Text } from 'preact-i18n'; +import { Text, MarkupText } from 'preact-i18n'; import cx from 'classnames'; import mainActions from '../../actions/main'; import style from './style.css'; @@ -110,6 +110,9 @@ class SetTabletMode extends Component { ))}
+

+ +

diff --git a/front/src/routes/dashboard/SetTabletMode.jsx b/front/src/routes/dashboard/SetTabletMode.jsx index 4ac80e206d..d24099f997 100644 --- a/front/src/routes/dashboard/SetTabletMode.jsx +++ b/front/src/routes/dashboard/SetTabletMode.jsx @@ -88,35 +88,39 @@ class SetTabletMode extends Component { [style.tabletModeDivOpen]: defineTabletModeOpened })} > -
-
-
-

- -

-
-
- -
- + - ))} - -
-

- -

-
- + {houses && + houses.map(house => ( + + ))} + +
+

+ +

+
+ +
+
From 5de3c023846cd25a93bda3142d4f584ff0abef86 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Thu, 26 Oct 2023 10:00:26 +0200 Subject: [PATCH 35/44] Add 0 to keypad --- front/src/config/i18n/en.json | 3 ++- front/src/config/i18n/fr.json | 3 ++- front/src/routes/locked/index.js | 12 +++++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 932a5ab4f9..8a4e536143 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -72,7 +72,8 @@ "description": "Please enter your code to unlock the alarm.", "codePlaceholder": "Enter your code", "error": "An error occurred, please try again", - "wrongCodeError": "Invalid code, please try again" + "wrongCodeError": "Invalid code, please try again", + "validateButton": "Validate" }, "signup": { "welcome": { diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 197ea076b5..817c999514 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -72,7 +72,8 @@ "description": "Merci de taper votre code afin de déverrouiller l'alarme.", "codePlaceholder": "Tapez votre code", "error": "Une erreur est survenue, merci de réessayer", - "wrongCodeError": "Code invalide, merci de réessayer" + "wrongCodeError": "Code invalide, merci de réessayer", + "validateButton": "Valider" }, "signup": { "welcome": { diff --git a/front/src/routes/locked/index.js b/front/src/routes/locked/index.js index 1764fd68db..0dafa12f84 100644 --- a/front/src/routes/locked/index.js +++ b/front/src/routes/locked/index.js @@ -42,6 +42,16 @@ const KeyPadComponent = ({ currentCode, typeLetter, clearPreviousLetter }) => ( ))} ))} + +
+
+
+ +
+
+
); @@ -162,7 +172,7 @@ class Locked extends Component { clearPreviousLetter={this.clearPreviousLetter} /> From f486805f776cd3a936bfbb4942c3fc617d86c151 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Thu, 26 Oct 2023 10:04:11 +0200 Subject: [PATCH 36/44] Fix eslint front --- front/src/routes/locked/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/front/src/routes/locked/index.js b/front/src/routes/locked/index.js index 0dafa12f84..3e8f33be08 100644 --- a/front/src/routes/locked/index.js +++ b/front/src/routes/locked/index.js @@ -44,13 +44,13 @@ const KeyPadComponent = ({ currentCode, typeLetter, clearPreviousLetter }) => ( ))}
-
+
-
+
); From 8f5c259f12285507a651ba1404c5180395584bba Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Thu, 26 Oct 2023 10:42:54 +0200 Subject: [PATCH 37/44] Fix typo FR --- front/src/config/i18n/fr.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 817c999514..881529debe 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -182,7 +182,7 @@ "validationError": "Erreur de validation : Le nom d'une maison doit-être compris entre 1 et 40 caractères.", "validationErrorRoom": "Erreur de validation : le nom d'une pièce doit être entre 1 et 40 caractères.", "alarmTitle": "Alarme", - "alarmDescription": "Si vous utilisez le mode alarme dans Gladys, vous pouvez configurer ici le code de désactivation de l'alarme, ainsi que délai avant le déclenchement de l'alarme. Ce délai sera appliqué lors d'un déclenchement manuel uniquement, pas dans les scènes.", + "alarmDescription": "Si vous utilisez le mode alarme dans Gladys, vous pouvez configurer ici le code de désactivation de l'alarme, ainsi que le délai avant le déclenchement de l'alarme. Ce délai sera appliqué lors d'un déclenchement manuel uniquement, pas dans les scènes.", "alarmCodeLabel": "Code de l'alarme", "alarmCodePlaceholder": "Entrez un code à 6 chiffres", "alarmDelayBeforeArmingLabel": "Délai avant armement de l'alarme", @@ -214,7 +214,7 @@ "toggleDefineTabletMode": "Mode tablette", "closeDefineTabletMode": "Fermer", "tabletMode": { - "description": "Le mode tablette sert à la fonctionnalité alarme. Si vous armez l'alarme, toutes les tablettes de la maison seront verrouillées et afficherons un clavier virtuel pour désactiver l'alarme.", + "description": "Le mode tablette sert à la fonctionnalité alarme. Si vous armez l'alarme, toutes les tablettes de la maison seront verrouillées et afficheront un clavier virtuel pour désactiver l'alarme.", "fullScreenForce": "Si vous voulez forcer une tablette à rester en mode plein écran, vous pouvez ajouter ?fullscreen=force à l'URL.", "houseLabel": "Maison", "tabletModeDisabled": "Mode tablette désactivé" @@ -379,7 +379,7 @@ "editBoxNamePlaceholder": "Entrez le nom du widget", "editHouseLabel": "Sélectionnez la maison qui est concernée pour l'activation/désactivation de l'alarme.", "alarmStatusText": "Votre maison est ", - "alarmArming": "Votre maison est entrain d'être armée...", + "alarmArming": "Votre maison est en train d'être armée...", "cancelAlarmArming": "Annuler" } } From 4eca35286594901c3a2864b914fa98d80bd3743c Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Thu, 26 Oct 2023 11:11:18 +0200 Subject: [PATCH 38/44] Add alarm set mode action --- front/src/config/i18n/en.json | 8 +- front/src/config/i18n/fr.json | 8 +- .../routes/scene/edit-scene/ActionCard.jsx | 12 +- .../actions/ChooseActionTypeCard.jsx | 3 +- .../scene/edit-scene/actions/SetAlarmMode.jsx | 123 ++++++++++++++++++ server/lib/house/house.arm.js | 25 +++- server/lib/scene/scene.actions.js | 16 ++- server/test/lib/house/house.arm.test.js | 38 +++++- .../actions/scene.action.setAlarmMode.test.js | 102 +++++++++++++++ server/utils/constants.js | 1 + 10 files changed, 323 insertions(+), 13 deletions(-) create mode 100644 front/src/routes/scene/edit-scene/actions/SetAlarmMode.jsx create mode 100644 server/test/lib/scene/actions/scene.action.setAlarmMode.test.js diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 8a4e536143..ed1b45743d 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -1531,6 +1531,11 @@ "description": "The scene will continue if the alarm is in the selected mode.", "houseLabel": "House", "alarmModeLabel": "Alarm Mode" + }, + "alarmSetMode": { + "description": "This action will set the selected house to the selected alarm mode.", + "houseLabel": "House", + "alarmModeLabel": "Alarm Mode" } }, "actions": { @@ -1583,7 +1588,8 @@ "condition": "Condition on Ecowatt (France)" }, "alarm": { - "check-alarm-mode": "If the alarm is in mode" + "check-alarm-mode": "If the alarm is in mode", + "set-alarm-mode": "Set alarm mode to" } }, "variables": { diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 881529debe..6df931f62c 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -1533,6 +1533,11 @@ "description": "La scène continuera si l'alarme est dans le mode sélectionné.", "houseLabel": "Maison", "alarmModeLabel": "Mode de l'alarme" + }, + "alarmSetMode": { + "description": "Cette action passera la maison sélectionnée dans le mode d'alarme sélectionné.", + "houseLabel": "Maison", + "alarmModeLabel": "Mode de l'alarme" } }, "actions": { @@ -1585,7 +1590,8 @@ "condition": "Condition sur Ecowatt ( France )" }, "alarm": { - "check-alarm-mode": "Si l'alarme est en mode" + "check-alarm-mode": "Si l'alarme est en mode", + "set-alarm-mode": "Passer l'alarme en mode" } }, "variables": { diff --git a/front/src/routes/scene/edit-scene/ActionCard.jsx b/front/src/routes/scene/edit-scene/ActionCard.jsx index 5ce52bc8e5..e9e08ecc67 100644 --- a/front/src/routes/scene/edit-scene/ActionCard.jsx +++ b/front/src/routes/scene/edit-scene/ActionCard.jsx @@ -26,6 +26,7 @@ import CalendarIsEventRunning from './actions/CalendarIsEventRunning'; import EcowattCondition from './actions/EcowattCondition'; import SendMessageCameraParams from './actions/SendMessageCameraParams'; import CheckAlarmMode from './actions/CheckAlarmMode'; +import SetAlarmMode from './actions/SetAlarmMode'; const deleteActionFromColumn = (columnIndex, rowIndex, deleteAction) => () => { deleteAction(columnIndex, rowIndex); @@ -54,7 +55,8 @@ const ACTION_ICON = { [ACTIONS.DEVICE.SET_VALUE]: 'fe fe-radio', [ACTIONS.CALENDAR.IS_EVENT_RUNNING]: 'fe fe-calendar', [ACTIONS.ECOWATT.CONDITION]: 'fe fe-zap', - [ACTIONS.ALARM.CHECK_ALARM_MODE]: 'fe fe-bell' + [ACTIONS.ALARM.CHECK_ALARM_MODE]: 'fe fe-bell', + [ACTIONS.ALARM.SET_ALARM_MODE]: 'fe fe-bell' }; const ACTION_CARD_TYPE = 'ACTION_CARD_TYPE'; @@ -342,6 +344,14 @@ const ActionCard = ({ children, ...props }) => { updateActionProperty={props.updateActionProperty} /> )} + {props.action.type === ACTIONS.ALARM.SET_ALARM_MODE && ( + + )}
diff --git a/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx b/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx index 8574af8627..ed34672725 100644 --- a/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx +++ b/front/src/routes/scene/edit-scene/actions/ChooseActionTypeCard.jsx @@ -28,7 +28,8 @@ const ACTION_LIST = [ ACTIONS.DEVICE.SET_VALUE, ACTIONS.CALENDAR.IS_EVENT_RUNNING, ACTIONS.ECOWATT.CONDITION, - ACTIONS.ALARM.CHECK_ALARM_MODE + ACTIONS.ALARM.CHECK_ALARM_MODE, + ACTIONS.ALARM.SET_ALARM_MODE ]; const TRANSLATIONS = ACTION_LIST.reduce((acc, action) => { diff --git a/front/src/routes/scene/edit-scene/actions/SetAlarmMode.jsx b/front/src/routes/scene/edit-scene/actions/SetAlarmMode.jsx new file mode 100644 index 0000000000..654ea54226 --- /dev/null +++ b/front/src/routes/scene/edit-scene/actions/SetAlarmMode.jsx @@ -0,0 +1,123 @@ +import Select from 'react-select'; +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import { Text } from 'preact-i18n'; +import withIntlAsProp from '../../../../utils/withIntlAsProp'; +import get from 'get-value'; + +import { ALARM_MODES_LIST } from '../../../../../../server/utils/constants'; + +const capitalizeFirstLetter = string => { + return string.charAt(0).toUpperCase() + string.slice(1); +}; + +class SetAlarmMode extends Component { + getOptions = async () => { + try { + const houses = await this.props.httpClient.get('/api/v1/house'); + const houseOptions = []; + houses.forEach(house => { + houseOptions.push({ + label: house.name, + value: house.selector + }); + }); + await this.setState({ houseOptions }); + this.refreshSelectedOptions(this.props); + } catch (e) { + console.error(e); + } + }; + handleHouseChange = selectedOption => { + if (selectedOption && selectedOption.value) { + this.props.updateActionProperty(this.props.columnIndex, this.props.index, 'house', selectedOption.value); + } else { + this.props.updateActionProperty(this.props.columnIndex, this.props.index, 'house', null); + } + }; + handleAlarmModeChange = selectedOption => { + if (selectedOption && selectedOption.value) { + this.props.updateActionProperty(this.props.columnIndex, this.props.index, 'alarm_mode', selectedOption.value); + } else { + this.props.updateActionProperty(this.props.columnIndex, this.props.index, 'alarm_mode', null); + } + }; + refreshSelectedOptions = nextProps => { + let selectedHouseOption = ''; + if (nextProps.action.house && this.state.houseOptions) { + const houseOption = this.state.houseOptions.find(option => option.value === nextProps.action.house); + + if (houseOption) { + selectedHouseOption = houseOption; + } + } + let selectedAlarmModeOption = ''; + if (nextProps.action.alarm_mode && this.state.alarmModesOptions) { + const alarmModeOption = this.state.alarmModesOptions.find(option => option.value === nextProps.action.alarm_mode); + + if (alarmModeOption) { + selectedAlarmModeOption = alarmModeOption; + } + } + this.setState({ selectedHouseOption, selectedAlarmModeOption }); + }; + constructor(props) { + super(props); + this.props = props; + const alarmModesOptions = ALARM_MODES_LIST.map(alarmMode => { + return { + value: alarmMode, + label: capitalizeFirstLetter(get(props.intl.dictionary, `alarmModes.${alarmMode}`, { default: alarmMode })) + }; + }); + this.state = { + alarmModesOptions, + selectedHouseOption: '' + }; + } + componentDidMount() { + this.getOptions(); + } + componentWillReceiveProps(nextProps) { + this.refreshSelectedOptions(nextProps); + } + render(props, { alarmModesOptions, houseOptions, selectedHouseOption, selectedAlarmModeOption }) { + return ( +
+

+ +

+
+ + +
+
+ ); + } +} + +export default withIntlAsProp(connect('httpClient', {})(SetAlarmMode)); diff --git a/server/lib/house/house.arm.js b/server/lib/house/house.arm.js index 90062da07d..2b93545b9d 100644 --- a/server/lib/house/house.arm.js +++ b/server/lib/house/house.arm.js @@ -6,12 +6,13 @@ const { NotFoundError, ConflictError } = require('../../utils/coreErrors'); /** * @public * @description Arm house Alarm. - * @param {object} selector - Selector of the house. + * @param {string} selector - Selector of the house. + * @param {boolean} disableWaitTime - Should not wait to arm. * @returns {Promise} Resolve with house object. * @example * const mainHouse = await gladys.house.arm('main-house'); */ -async function arm(selector) { +async function arm(selector, disableWaitTime = false) { const house = await db.House.findOne({ where: { selector, @@ -38,8 +39,10 @@ async function arm(selector) { type: EVENTS.ALARM.ARMING, house: selector, }); - // Wait the delay before arming - const currentTimeout = setTimeout(async () => { + + const waitTimeInMs = disableWaitTime ? 0 : house.alarm_delay_before_arming * 1000; + + const armHouse = async () => { // Update database await house.update({ alarm_mode: ALARM_MODES.ARMED }); // Lock all tablets in this house @@ -56,10 +59,18 @@ async function arm(selector) { house: selector, }, }); - }, house.alarm_delay_before_arming * 1000); + }; - // store the timeout so we can cancel it if needed - this.armingHouseTimeout.set(selector, currentTimeout); + // if the wait time is 0, just arm now + if (waitTimeInMs === 0) { + await armHouse(); + } else { + // Wait the delay before arming + const currentTimeout = setTimeout(armHouse, waitTimeInMs); + + // store the timeout so we can cancel it if needed + this.armingHouseTimeout.set(selector, currentTimeout); + } } module.exports = { diff --git a/server/lib/scene/scene.actions.js b/server/lib/scene/scene.actions.js index d4138422da..fc22120c00 100644 --- a/server/lib/scene/scene.actions.js +++ b/server/lib/scene/scene.actions.js @@ -18,7 +18,7 @@ const get = require('get-value'); const dayjs = require('dayjs'); const utc = require('dayjs/plugin/utc'); const timezone = require('dayjs/plugin/timezone'); -const { ACTIONS, DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../utils/constants'); +const { ACTIONS, DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES, ALARM_MODES } = require('../../utils/constants'); const { getDeviceFeature } = require('../../utils/device'); const { AbortScene } = require('../../utils/coreErrors'); const { compare } = require('../../utils/compare'); @@ -443,6 +443,20 @@ const actionsFunc = { throw new AbortScene(`House "${house.name}" is not in mode ${action.alarm_mode}`); } }, + [ACTIONS.ALARM.SET_ALARM_MODE]: async (self, action) => { + if (action.alarm_mode === ALARM_MODES.ARMED) { + await self.house.arm(action.house, true); + } + if (action.alarm_mode === ALARM_MODES.DISARMED) { + await self.house.disarm(action.house); + } + if (action.alarm_mode === ALARM_MODES.PARTIALLY_ARMED) { + await self.house.partialArm(action.house); + } + if (action.alarm_mode === ALARM_MODES.PANIC) { + await self.house.panic(action.house); + } + }, }; module.exports = { diff --git a/server/test/lib/house/house.arm.test.js b/server/test/lib/house/house.arm.test.js index 875bb9c7d1..d7b052c35d 100644 --- a/server/test/lib/house/house.arm.test.js +++ b/server/test/lib/house/house.arm.test.js @@ -20,7 +20,7 @@ describe('house.arm', () => { const house = new House(event, {}, session); beforeEach(async () => { await house.update('test-house', { - alarm_delay_before_arming: 0, + alarm_delay_before_arming: 0.001, }); sinon.reset(); }); @@ -64,6 +64,42 @@ describe('house.arm', () => { }, ]); }); + it('should arm a house immediately', async () => { + await house.arm('test-house', true); + assert.callCount(event.emit, 4); + expect(event.emit.firstCall.args).to.deep.equal([ + EVENTS.WEBSOCKET.SEND_ALL, + { + type: WEBSOCKET_MESSAGE_TYPES.ALARM.ARMING, + payload: { + house: 'test-house', + }, + }, + ]); + expect(event.emit.secondCall.args).to.deep.equal([ + EVENTS.TRIGGERS.CHECK, + { + type: EVENTS.ALARM.ARMING, + house: 'test-house', + }, + ]); + expect(event.emit.thirdCall.args).to.deep.equal([ + EVENTS.TRIGGERS.CHECK, + { + type: EVENTS.ALARM.ARM, + house: 'test-house', + }, + ]); + expect(event.emit.args[3]).to.deep.equal([ + EVENTS.WEBSOCKET.SEND_ALL, + { + type: WEBSOCKET_MESSAGE_TYPES.ALARM.ARMED, + payload: { + house: 'test-house', + }, + }, + ]); + }); it('should return house not found', async () => { const promise = house.arm('house-not-found'); return assertChai.isRejected(promise, 'House not found'); diff --git a/server/test/lib/scene/actions/scene.action.setAlarmMode.test.js b/server/test/lib/scene/actions/scene.action.setAlarmMode.test.js new file mode 100644 index 0000000000..60481bb8f5 --- /dev/null +++ b/server/test/lib/scene/actions/scene.action.setAlarmMode.test.js @@ -0,0 +1,102 @@ +const { fake, assert } = require('sinon'); +const EventEmitter = require('events'); + +const { ACTIONS } = require('../../../../utils/constants'); +const { executeActions } = require('../../../../lib/scene/scene.executeActions'); + +const StateManager = require('../../../../lib/state'); + +describe('scene.set-alarm-mode', () => { + let event; + let stateManager; + + beforeEach(() => { + event = new EventEmitter(); + stateManager = new StateManager(event); + }); + + it('should arm house', async () => { + const house = { + getBySelector: fake.resolves({ name: 'my house', alarm_mode: 'disarmed' }), + arm: fake.resolves(null), + }; + const scope = {}; + await executeActions( + { stateManager, event, house }, + [ + [ + { + type: ACTIONS.ALARM.SET_ALARM_MODE, + house: 'my-house', + alarm_mode: 'armed', + }, + ], + ], + scope, + ); + assert.calledWith(house.arm, 'my-house', true); + }); + it('should disarm house', async () => { + const house = { + getBySelector: fake.resolves({ name: 'my house', alarm_mode: 'armed' }), + disarm: fake.resolves(null), + }; + const scope = {}; + await executeActions( + { stateManager, event, house }, + [ + [ + { + type: ACTIONS.ALARM.SET_ALARM_MODE, + house: 'my-house', + alarm_mode: 'disarmed', + }, + ], + ], + scope, + ); + assert.calledWith(house.disarm, 'my-house'); + }); + it('should partially arm house', async () => { + const house = { + getBySelector: fake.resolves({ name: 'my house', alarm_mode: 'disarmed' }), + partialArm: fake.resolves(null), + }; + const scope = {}; + await executeActions( + { stateManager, event, house }, + [ + [ + { + type: ACTIONS.ALARM.SET_ALARM_MODE, + house: 'my-house', + alarm_mode: 'partially-armed', + }, + ], + ], + scope, + ); + assert.calledWith(house.partialArm, 'my-house'); + }); + it('should put house in panic mode', async () => { + const house = { + getBySelector: fake.resolves({ name: 'my house', alarm_mode: 'disarmed' }), + panic: fake.resolves(null), + }; + const scope = {}; + await executeActions( + { stateManager, event, house }, + [ + [ + { + type: ACTIONS.ALARM.SET_ALARM_MODE, + house: 'my-house', + alarm_mode: 'panic', + }, + ], + ], + scope, + ); + assert.calledWith(house.panic, 'my-house'); + }); +}); diff --git a/server/utils/constants.js b/server/utils/constants.js index fc0ed065ef..a69b135b9b 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -295,6 +295,7 @@ const CONDITIONS = { const ACTIONS = { ALARM: { CHECK_ALARM_MODE: 'alarm.check-alarm-mode', + SET_ALARM_MODE: 'alarm.set-alarm-mode', }, CALENDAR: { IS_EVENT_RUNNING: 'calendar.is-event-running', From f7a78a0a8d7d0d3f3d573d2b04baf613e64d7664 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 27 Oct 2023 09:53:24 +0200 Subject: [PATCH 39/44] Add more validation on house alarm code --- front/src/components/house/EditHouse.jsx | 12 +++++++++--- .../components/house/EditHouseComponent.jsx | 19 ++++++++++++++++++- front/src/config/i18n/en.json | 2 +- front/src/config/i18n/fr.json | 3 ++- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/front/src/components/house/EditHouse.jsx b/front/src/components/house/EditHouse.jsx index 7c303fc8fe..0ee31d923b 100644 --- a/front/src/components/house/EditHouse.jsx +++ b/front/src/components/house/EditHouse.jsx @@ -111,7 +111,9 @@ const EditHouse = ({ children, ...props }) => ( type={props.showAlarmCode ? 'text' : 'password'} placeholder={} value={props.house.alarm_code} - className="form-control" + class={cx('form-control', { + 'is-invalid': get(props, 'errors.alarm_code') + })} onInput={props.updateHouseAlarmCode} /> @@ -124,7 +126,11 @@ const EditHouse = ({ children, ...props }) => ( /> -
+
@@ -162,7 +168,7 @@ const EditHouse = ({ children, ...props }) => (
- {!props.wantToDeleteHouse && ( diff --git a/front/src/components/house/EditHouseComponent.jsx b/front/src/components/house/EditHouseComponent.jsx index 0da0836b78..b1dcd5e66a 100644 --- a/front/src/components/house/EditHouseComponent.jsx +++ b/front/src/components/house/EditHouseComponent.jsx @@ -3,6 +3,20 @@ import { Component } from 'preact'; import EditHouse from './EditHouse'; class EditHouseComponent extends Component { + getErrors = () => { + const errors = {}; + if (this.props.house.alarm_code) { + const code = this.props.house.alarm_code; + const isNum = /^\d+$/.test(code); + if (!isNum) { + errors.alarm_code = true; + } + if (code.length < 4 || code.length > 8) { + errors.alarm_code = true; + } + } + return errors; + }; updateNewRoomName = e => { this.setState({ newRoomName: e.target.value @@ -12,7 +26,8 @@ class EditHouseComponent extends Component { this.props.updateHouseName(e.target.value, this.props.houseIndex); }; updateHouseAlarmCode = e => { - this.props.updateHouseAlarmCode(e.target.value, this.props.houseIndex); + const alarmCode = e.target.value && e.target.value.length ? e.target.value : null; + this.props.updateHouseAlarmCode(alarmCode, this.props.houseIndex); }; updateHouseDelayBeforeArming = e => { this.props.updateHouseDelayBeforeArming(e.target.value, this.props.houseIndex); @@ -78,6 +93,7 @@ class EditHouseComponent extends Component { } render(props, { newRoomName, wantToDeleteHouse, loading, showAlarmCode }) { + const errors = this.getErrors(); return ( ); } diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index ed1b45743d..411a938bff 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -184,7 +184,7 @@ "alarmTitle": "Alarm", "alarmDescription": "If you are using the alarm mode in Gladys, you can configure the alarm deactivation code and the delay before the alarm triggers here. This delay will only apply in the case of manual triggering and not in scenes.", "alarmCodeLabel": "Alarm Code", - "alarmCodePlaceholder": "Enter a 6-digit code", + "alarmCodePlaceholder": "Enter a numeric code between 4 and 8 digits", "alarmDelayBeforeArmingLabel": "Delay before arming the alarm", "alarmDelays": { "0": "No delay", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 6df931f62c..d92dab9b8f 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -184,8 +184,9 @@ "alarmTitle": "Alarme", "alarmDescription": "Si vous utilisez le mode alarme dans Gladys, vous pouvez configurer ici le code de désactivation de l'alarme, ainsi que le délai avant le déclenchement de l'alarme. Ce délai sera appliqué lors d'un déclenchement manuel uniquement, pas dans les scènes.", "alarmCodeLabel": "Code de l'alarme", - "alarmCodePlaceholder": "Entrez un code à 6 chiffres", + "alarmCodePlaceholder": "Entrez un code numériques entre 4 et 8 chiffres", "alarmDelayBeforeArmingLabel": "Délai avant armement de l'alarme", + "alarmCodeError": "Le code doit être numérique et entre 4 et 8 chiffres.", "alarmDelays": { "0": "Pas de délai", "5": "5 secondes", From 593a291c6eb2896563257335ae700f25b967ccd7 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 27 Oct 2023 10:15:36 +0200 Subject: [PATCH 40/44] Add autocomplete=off and fake password to prevent brower from autocompleting --- front/src/routes/locked/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/front/src/routes/locked/index.js b/front/src/routes/locked/index.js index 3e8f33be08..9616f8e588 100644 --- a/front/src/routes/locked/index.js +++ b/front/src/routes/locked/index.js @@ -17,11 +17,13 @@ const BUTTON_ARRAY = [ const KeyPadComponent = ({ currentCode, typeLetter, clearPreviousLetter }) => (
+ } /> @@ -165,7 +167,7 @@ class Locked extends Component {
)} -
+
Date: Fri, 27 Oct 2023 10:52:27 +0200 Subject: [PATCH 41/44] Improve design of locked page + improve robustness --- front/src/routes/dashboard/index.js | 17 ++++++++++++++--- front/src/routes/locked/index.js | 18 +++++++++++------- front/src/routes/locked/style.css | 18 +++++++++++++++++- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/front/src/routes/dashboard/index.js b/front/src/routes/dashboard/index.js index 1be93376bf..38b9f1e919 100644 --- a/front/src/routes/dashboard/index.js +++ b/front/src/routes/dashboard/index.js @@ -155,9 +155,20 @@ class Dashboard extends Component { this.props.setFullScreen(isFullScreen); }; - alarmArmed = () => { - if (this.props.tabletMode) { - route('/locked'); + alarmArmed = async () => { + // Check server side if we are in tablet mode + try { + const currentSession = await this.props.httpClient.get('/api/v1/session/tablet_mode'); + if (currentSession.tablet_mode) { + route('/locked'); + } + } catch (e) { + console.error(e); + const status = get(e, 'response.status'); + const errorMessageOtherFormat = get(e, 'response.data.message'); + if (status === 401 && errorMessageOtherFormat === 'TABLET_IS_LOCKED') { + route('/locked'); + } } }; diff --git a/front/src/routes/locked/index.js b/front/src/routes/locked/index.js index 9616f8e588..3ae580e4d0 100644 --- a/front/src/routes/locked/index.js +++ b/front/src/routes/locked/index.js @@ -21,14 +21,15 @@ const KeyPadComponent = ({ currentCode, typeLetter, clearPreviousLetter }) => ( } />
-
@@ -37,7 +38,7 @@ const KeyPadComponent = ({ currentCode, typeLetter, clearPreviousLetter }) => (
{row.map(cell => (
-
@@ -48,7 +49,7 @@ const KeyPadComponent = ({ currentCode, typeLetter, clearPreviousLetter }) => (
-
@@ -83,7 +84,7 @@ class Locked extends Component { route('/dashboard'); } catch (e) { this.props.httpClient.setApiScopes(['alarm:write']); - this.props.httpClient.refreshAccessToken(); + await this.props.httpClient.refreshAccessToken(); } }; constructor(props) { @@ -135,7 +136,7 @@ class Locked extends Component { return (
-