From 2f902a6758abd3b35423fb3e76af363dddb86cb8 Mon Sep 17 00:00:00 2001 From: callemand Date: Thu, 28 Sep 2023 09:57:00 +0200 Subject: [PATCH 1/9] Dashboard: User can define the thresholds of the room temperature (#1888) --- .../boxs/room-humidity/RoomHumidity.jsx | 2 +- .../EditRoomTemperatureBox.jsx | 99 ++++++++++++++++++- .../boxs/room-temperature/RoomTemperature.jsx | 65 ++++++++++-- front/src/config/i18n/en.json | 3 +- front/src/config/i18n/fr.json | 3 +- front/src/style/index.css | 47 ++++++++- server/models/dashboard.js | 3 + server/utils/constants.js | 6 ++ 8 files changed, 213 insertions(+), 15 deletions(-) diff --git a/front/src/components/boxs/room-humidity/RoomHumidity.jsx b/front/src/components/boxs/room-humidity/RoomHumidity.jsx index 3d6d174d48..1acdab102a 100644 --- a/front/src/components/boxs/room-humidity/RoomHumidity.jsx +++ b/front/src/components/boxs/room-humidity/RoomHumidity.jsx @@ -30,7 +30,7 @@ const RoomHumidityBox = ({ children, ...props }) => ( )} {!isNotNullOrUndefined(props.humidity) && ( - + )} diff --git a/front/src/components/boxs/room-temperature/EditRoomTemperatureBox.jsx b/front/src/components/boxs/room-temperature/EditRoomTemperatureBox.jsx index 0030b1a5c8..d118121bf5 100644 --- a/front/src/components/boxs/room-temperature/EditRoomTemperatureBox.jsx +++ b/front/src/components/boxs/room-temperature/EditRoomTemperatureBox.jsx @@ -2,14 +2,18 @@ import { Component } from 'preact'; import { connect } from 'unistore/preact'; import { Text } from 'preact-i18n'; import BaseEditBox from '../baseEditBox'; +import ReactSlider from 'react-slider'; +import { DEFAULT_VALUE_TEMPERATURE, DEVICE_FEATURE_UNITS } from '../../../../../server/utils/constants'; import RoomSelector from '../../house/RoomSelector'; +import cx from 'classnames'; +import { celsiusToFahrenheit, fahrenheitToCelsius } from '../../../../../server/utils/units'; const updateBoxRoom = (updateBoxRoomFunc, x, y) => room => { updateBoxRoomFunc(x, y, room.selector); }; -const EditRoomTemperatureBox = ({ children, ...props }) => ( +const EditRoomTemperatureBox = ({ children, unit, ...props }) => (
+
+ +
+
+ ( +
+ + +
+ )} + pearling + minDistance={10} + max={unit === DEVICE_FEATURE_UNITS.CELSIUS ? 50 : 122} + min={unit === DEVICE_FEATURE_UNITS.CELSIUS ? -20 : -4} + onAfterChange={props.updateBoxValue} + value={[props.temperatureMin, props.temperatureMax]} + disabled={!(props.box.temperature_use_custom_value || false)} + /> +
); @@ -29,9 +68,63 @@ class EditRoomTemperatureBoxComponent extends Component { room }); }; + + updateBoxUseCustomValue = e => { + this.props.updateBoxConfig(this.props.x, this.props.y, { + temperature_use_custom_value: e.target.checked + }); + }; + + updateBoxValue = values => { + let temperature_min = values[0]; + let temperature_max = values[1]; + + if (this.props.user.temperature_unit_preference === DEVICE_FEATURE_UNITS.FAHRENHEIT) { + temperature_min = fahrenheitToCelsius(temperature_min); + temperature_max = fahrenheitToCelsius(temperature_max); + } + + this.props.updateBoxConfig(this.props.x, this.props.y, { + temperature_min, + temperature_max + }); + }; + render(props, {}) { - return ; + let temperature_min = this.props.box.temperature_min; + let temperature_max = this.props.box.temperature_max; + + const unit = this.props.user.temperature_unit_preference; + + if (!this.props.box.temperature_use_custom_value) { + temperature_min = DEFAULT_VALUE_TEMPERATURE.MINIMUM; + temperature_max = DEFAULT_VALUE_TEMPERATURE.MAXIMUM; + } + + if (isNaN(temperature_min)) { + temperature_min = DEFAULT_VALUE_TEMPERATURE.MINIMUM; + } + if (isNaN(temperature_max)) { + temperature_max = DEFAULT_VALUE_TEMPERATURE.MAXIMUM; + } + + if (this.props.user.temperature_unit_preference === DEVICE_FEATURE_UNITS.FAHRENHEIT) { + temperature_min = celsiusToFahrenheit(temperature_min); + temperature_max = celsiusToFahrenheit(temperature_max); + } + + return ( + + ); } } -export default connect('', {})(EditRoomTemperatureBoxComponent); +export default connect('user', {})(EditRoomTemperatureBoxComponent); diff --git a/front/src/components/boxs/room-temperature/RoomTemperature.jsx b/front/src/components/boxs/room-temperature/RoomTemperature.jsx index 8702b3b58b..db0dd46c91 100644 --- a/front/src/components/boxs/room-temperature/RoomTemperature.jsx +++ b/front/src/components/boxs/room-temperature/RoomTemperature.jsx @@ -5,24 +5,49 @@ import get from 'get-value'; import actions from '../../../actions/dashboard/boxes/temperatureInRoom'; import { DASHBOARD_BOX_STATUS_KEY, DASHBOARD_BOX_DATA_KEY } from '../../../utils/consts'; -import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../server/utils/constants'; +import { + DEFAULT_VALUE_TEMPERATURE, + DEVICE_FEATURE_UNITS, + WEBSOCKET_MESSAGE_TYPES +} from '../../../../../server/utils/constants'; +import { celsiusToFahrenheit } from '../../../../../server/utils/units'; const isNotNullOrUndefined = value => value !== undefined && value !== null; const RoomTemperatureBox = ({ children, ...props }) => (
- - - + {isNotNullOrUndefined(props.temperature) && + props.temperature >= props.temperatureMin && + props.temperature <= props.temperatureMax && ( + + + + )} + {isNotNullOrUndefined(props.temperature) && props.temperature < props.temperatureMin && ( + + + + )} + {isNotNullOrUndefined(props.temperature) && props.temperature > props.temperatureMax && ( + + + + )} + {!isNotNullOrUndefined(props.temperature) && ( + + + + )} +
- {props.valued && ( + {isNotNullOrUndefined(props.temperature) && (

)} - {!props.valued && ( + {!isNotNullOrUndefined(props.temperature) && (

@@ -64,7 +89,29 @@ class RoomTemperatureBoxComponent extends Component { const temperature = get(boxData, 'room.temperature.temperature'); const unit = get(boxData, 'room.temperature.unit'); const roomName = get(boxData, 'room.name'); - const valued = isNotNullOrUndefined(temperature); + + const temperature_use_custom_value = get(props, 'box.temperature_use_custom_value'); + let temperature_min = get(props, 'box.temperature_min'); + let temperature_max = get(props, 'box.temperature_max'); + + if (!temperature_use_custom_value) { + temperature_min = DEFAULT_VALUE_TEMPERATURE.MINIMUM; + temperature_max = DEFAULT_VALUE_TEMPERATURE.MAXIMUM; + } + + if (isNaN(temperature_min)) { + temperature_min = DEFAULT_VALUE_TEMPERATURE.MINIMUM; + } + if (isNaN(temperature_max)) { + temperature_max = DEFAULT_VALUE_TEMPERATURE.MAXIMUM; + } + + console.log('unit', unit); + + if (unit === DEVICE_FEATURE_UNITS.FAHRENHEIT) { + temperature_min = celsiusToFahrenheit(temperature_min); + temperature_max = celsiusToFahrenheit(temperature_min); + } return ( ); } diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 2dc2add5dd..a2eb8c0e49 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -253,7 +253,8 @@ }, "temperatureInRoom": { "editRoomLabel": "Select the room you want to display here.", - "noTemperatureRecorded": "No temperature recorded recently." + "noTemperatureRecorded": "No temperature recorded recently.", + "thresholdsLabel": "Configure custom thresholds" }, "humidityInRoom": { "editRoomLabel": "Select the room you want to display here.", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 3288aa9514..9f36ee3ee0 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -253,7 +253,8 @@ }, "temperatureInRoom": { "editRoomLabel": "Sélectionnez la pièce que vous souhaitez afficher ici.", - "noTemperatureRecorded": "Aucune température enregistrée récemment." + "noTemperatureRecorded": "Aucune température enregistrée récemment.", + "thresholdsLabel": "Configurer des seuils personnalisés" }, "humidityInRoom": { "editRoomLabel": "Sélectionnez la pièce que vous souhaitez afficher ici.", diff --git a/front/src/style/index.css b/front/src/style/index.css index def1688683..80e315ecf0 100644 --- a/front/src/style/index.css +++ b/front/src/style/index.css @@ -323,7 +323,6 @@ body { font-size: 0.9em; text-align: center; background-color: #fafafa; - #color: white; cursor: pointer; border: 1px solid gray; border-radius: 10px; @@ -358,3 +357,49 @@ body { height: 24px; line-height: 22px; } + + +.temperature-slider { + width: 100%; + max-width: 500px; + height: 30px; +} + +.temperature-slider-thumb { + font-size: 0.9em; + text-align: center; + background-color: #fafafa; + cursor: pointer; + border: 1px solid gray; + border-radius: 10px; + box-sizing: border-box; +} + +.temperature-slider-thumb.active { + border: 1px solid #467fcf; +} + +.temperature-slider-track { + position: relative; + background: #467fcf; +} + +.temperature-slider-track-1 { + background: #5eba00; +} + +.temperature-slider-track-2 { + background: #cd201f; +} + +.temperature-slider .temperature-slider-track { + top: 10px; + height: 10px; +} + +.temperature-slider .temperature-slider-thumb { + top: 3px; + width: 41px; + height: 24px; + line-height: 22px; +} diff --git a/server/models/dashboard.js b/server/models/dashboard.js index e32929342d..3aa17a394f 100644 --- a/server/models/dashboard.js +++ b/server/models/dashboard.js @@ -32,6 +32,9 @@ const boxesSchema = Joi.array().items( humidity_use_custom_value: Joi.boolean(), humidity_min: Joi.number(), humidity_max: Joi.number(), + temperature_use_custom_value: Joi.boolean(), + temperature_min: Joi.number(), + temperature_max: Joi.number(), }), ), ); diff --git a/server/utils/constants.js b/server/utils/constants.js index ab4b91e31d..06d7c44e91 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -949,6 +949,11 @@ const DEFAULT_VALUE_HUMIDITY = { MAXIMUM: 60, }; +const DEFAULT_VALUE_TEMPERATURE = { + MINIMUM: 17, + MAXIMUM: 24, +}; + const createList = (obj) => { const list = []; Object.keys(obj).forEach((key) => { @@ -1046,3 +1051,4 @@ module.exports.JOB_ERROR_TYPES = JOB_ERROR_TYPES; 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; From 94246679a739ee5d9ab624890f3f7bf6898027e9 Mon Sep 17 00:00:00 2001 From: callemand Date: Thu, 28 Sep 2023 10:23:18 +0200 Subject: [PATCH 2/9] Tuya: Add curtain devices (#1885) --- .../tuya/lib/device/tuya.deviceMapping.js | 46 ++++++++++++++++++- .../device/feature/tuya.deviceMapping.test.js | 44 +++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/server/services/tuya/lib/device/tuya.deviceMapping.js b/server/services/tuya/lib/device/tuya.deviceMapping.js index 26d4519547..ee76cd650f 100644 --- a/server/services/tuya/lib/device/tuya.deviceMapping.js +++ b/server/services/tuya/lib/device/tuya.deviceMapping.js @@ -1,4 +1,4 @@ -const { DEVICE_FEATURE_TYPES, DEVICE_FEATURE_CATEGORIES } = require('../../../../utils/constants'); +const { DEVICE_FEATURE_TYPES, DEVICE_FEATURE_CATEGORIES, COVER_STATE } = require('../../../../utils/constants'); const { intToRgb, rgbToHsb, rgbToInt, hsbToRgb } = require('../../../../utils/colors'); const SWITCH_LED = 'switch_led'; @@ -13,6 +13,13 @@ const SWITCH_2 = 'switch_2'; const SWITCH_3 = 'switch_3'; const SWITCH_4 = 'switch_4'; +const CONTROL = 'control'; +const PERCENT_CONTROL = 'percent_control'; + +const OPEN = 'open'; +const CLOSE = 'close'; +const STOP = 'stop'; + const mappings = { [SWITCH_LED]: { category: DEVICE_FEATURE_CATEGORIES.LIGHT, @@ -51,6 +58,14 @@ const mappings = { category: DEVICE_FEATURE_CATEGORIES.SWITCH, type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, }, + [CONTROL]: { + category: DEVICE_FEATURE_CATEGORIES.CURTAIN, + type: DEVICE_FEATURE_TYPES.CURTAIN.STATE, + }, + [PERCENT_CONTROL]: { + category: DEVICE_FEATURE_CATEGORIES.CURTAIN, + type: DEVICE_FEATURE_TYPES.CURTAIN.POSITION, + }, }; const writeValues = { @@ -80,6 +95,21 @@ const writeValues = { return valueFromGladys === 1; }, }, + + [DEVICE_FEATURE_CATEGORIES.CURTAIN]: { + [DEVICE_FEATURE_TYPES.CURTAIN.STATE]: (valueFromGladys) => { + if (valueFromGladys === COVER_STATE.OPEN) { + return OPEN; + } + if (valueFromGladys === COVER_STATE.CLOSE) { + return CLOSE; + } + return STOP; + }, + [DEVICE_FEATURE_TYPES.CURTAIN.POSITION]: (valueFromGladys) => { + return parseInt(valueFromGladys, 10); + }, + }, }; const readValues = { @@ -106,6 +136,20 @@ const readValues = { return valueFromDevice === true ? 1 : 0; }, }, + [DEVICE_FEATURE_CATEGORIES.CURTAIN]: { + [DEVICE_FEATURE_TYPES.CURTAIN.STATE]: (valueFromDevice) => { + if (valueFromDevice === OPEN) { + return COVER_STATE.OPEN; + } + if (valueFromDevice === CLOSE) { + return COVER_STATE.CLOSE; + } + return COVER_STATE.STOP; + }, + [DEVICE_FEATURE_TYPES.CURTAIN.POSITION]: (valueFromDevice) => { + return valueFromDevice; + }, + }, }; module.exports = { mappings, readValues, writeValues }; diff --git a/server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js b/server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js index af8fb961a7..410be92c79 100644 --- a/server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js +++ b/server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js @@ -1,5 +1,5 @@ const { expect } = require('chai'); -const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../../../utils/constants'); +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES, COVER_STATE } = require('../../../../../../utils/constants'); const { writeValues, readValues } = require('../../../../../../services/tuya/lib/device/tuya.deviceMapping'); describe('Tuya device mapping', () => { @@ -24,6 +24,30 @@ describe('Tuya device mapping', () => { const result = writeValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.BINARY](1); expect(result).to.eq(true); }); + describe('curtain state', () => { + it('open', () => { + const result = writeValues[DEVICE_FEATURE_CATEGORIES.CURTAIN][DEVICE_FEATURE_TYPES.CURTAIN.STATE]( + COVER_STATE.OPEN, + ); + expect(result).to.eq('open'); + }); + it('close', () => { + const result = writeValues[DEVICE_FEATURE_CATEGORIES.CURTAIN][DEVICE_FEATURE_TYPES.CURTAIN.STATE]( + COVER_STATE.CLOSE, + ); + expect(result).to.eq('close'); + }); + it('stop', () => { + const result = writeValues[DEVICE_FEATURE_CATEGORIES.CURTAIN][DEVICE_FEATURE_TYPES.CURTAIN.STATE]( + COVER_STATE.STOP, + ); + expect(result).to.eq('stop'); + }); + }); + it('custain position', () => { + const result = writeValues[DEVICE_FEATURE_CATEGORIES.CURTAIN][DEVICE_FEATURE_TYPES.CURTAIN.POSITION]('30'); + expect(result).to.eq(30); + }); }); describe('read value', () => { @@ -49,5 +73,23 @@ describe('Tuya device mapping', () => { const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.BINARY](true); expect(result).to.eq(1); }); + describe('curtain state', () => { + it('open', () => { + const result = readValues[DEVICE_FEATURE_CATEGORIES.CURTAIN][DEVICE_FEATURE_TYPES.CURTAIN.STATE]('open'); + expect(result).to.eq(COVER_STATE.OPEN); + }); + it('close', () => { + const result = readValues[DEVICE_FEATURE_CATEGORIES.CURTAIN][DEVICE_FEATURE_TYPES.CURTAIN.STATE]('close'); + expect(result).to.eq(COVER_STATE.CLOSE); + }); + it('stop', () => { + const result = readValues[DEVICE_FEATURE_CATEGORIES.CURTAIN][DEVICE_FEATURE_TYPES.CURTAIN.STATE]('STOP'); + expect(result).to.eq(COVER_STATE.STOP); + }); + it('curtain position', () => { + const result = readValues[DEVICE_FEATURE_CATEGORIES.CURTAIN][DEVICE_FEATURE_TYPES.CURTAIN.POSITION](30); + expect(result).to.eq(30); + }); + }); }); }); From c8e168b642f2a4847f2d7768953a107d65471d92 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Thu, 28 Sep 2023 10:24:49 +0200 Subject: [PATCH 3/9] Dashboard: Add a list of device feature categories that don't expires in time (#1884) --- .../sensor-value/SensorDeviceFeature.jsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx b/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx index 197daef96a..c71c33f6d8 100644 --- a/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx +++ b/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx @@ -26,6 +26,13 @@ const DISPLAY_BY_FEATURE_TYPE = { [DEVICE_FEATURE_TYPES.SENSOR.BINARY]: BinaryDeviceValue }; +const DEVICE_FEATURES_WITHOUT_EXPIRATION = [ + DEVICE_FEATURE_CATEGORIES.SMOKE_SENSOR, + DEVICE_FEATURE_CATEGORIES.LEAK_SENSOR, + DEVICE_FEATURE_CATEGORIES.BUTTON, + DEVICE_FEATURE_CATEGORIES.TEXT +]; + const SensorDeviceType = ({ children, ...props }) => { const { deviceFeature: feature } = props; const { category, type } = feature; @@ -40,8 +47,9 @@ const SensorDeviceType = ({ children, ...props }) => { elementType = BadgeNumberDeviceValue; } - // If the device feature has no recent value, we display a message to the user - if (feature.last_value_is_too_old) { + // If the device feature has no recent value, and the feature is not in the blacklist + // we display a message to the user + if (feature.last_value_is_too_old && DEVICE_FEATURES_WITHOUT_EXPIRATION.indexOf(feature.category) === -1) { elementType = NoRecentValueBadge; } From e223e3f69ce7b82d15542c8be8ee87fb5e2cbc2b Mon Sep 17 00:00:00 2001 From: Quentin L Date: Thu, 28 Sep 2023 10:26:09 +0200 Subject: [PATCH 4/9] Dashboard: Fix weather widget translations after API changes (#1899) --- front/src/config/i18n/en.json | 4 ++-- front/src/config/i18n/fr.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index a2eb8c0e49..91ab09fb13 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -231,8 +231,8 @@ "editModeLabel": "Select display mode:", "displayModes": { "advancedWeather": "Humidity and wind speed", - "hourlyForecast": "Forecast for the next 8 hours", - "dailyForecast": "Forecast for the next 7 days" + "hourlyForecast": "Forecast for the next 24 hours", + "dailyForecast": "Forecast for the next 5 days" }, "minMaxDegreeValue": "{{min}}°/{{max}}°" }, diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 9f36ee3ee0..d9483bcc62 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -231,8 +231,8 @@ "editModeLabel": "Mode d'affichage :", "displayModes": { "advancedWeather": "Humidité et vitesse du vent", - "hourlyForecast": "Prévisions pour les 8 prochaines heures", - "dailyForecast": "Prévisions pour les 7 prochains jours" + "hourlyForecast": "Prévisions pour les 24 prochaines heures", + "dailyForecast": "Prévisions pour les 5 prochains jours" }, "minMaxDegreeValue": "{{min}}°/{{max}}°" }, From 3760147ade30cca4a0696cdb27c6fa68149fee1e Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Thu, 28 Sep 2023 10:42:35 +0200 Subject: [PATCH 5/9] Dashboard: Add ?fullscreen=force GET parameter for tablet mode (#1883) --- front/src/actions/dashboard/index.js | 12 +++++ front/src/routes/dashboard/DashboardPage.jsx | 24 +++++---- front/src/routes/dashboard/index.js | 55 ++++++++++++++------ 3 files changed, 63 insertions(+), 28 deletions(-) create mode 100644 front/src/actions/dashboard/index.js diff --git a/front/src/actions/dashboard/index.js b/front/src/actions/dashboard/index.js new file mode 100644 index 0000000000..2fdf088da1 --- /dev/null +++ b/front/src/actions/dashboard/index.js @@ -0,0 +1,12 @@ +function createActions(store) { + const actions = { + setFullScreen(state, fullScreen) { + store.setState({ + fullScreen + }); + } + }; + return actions; +} + +export default createActions; diff --git a/front/src/routes/dashboard/DashboardPage.jsx b/front/src/routes/dashboard/DashboardPage.jsx index 71a096f5c5..4089c5c49c 100644 --- a/front/src/routes/dashboard/DashboardPage.jsx +++ b/front/src/routes/dashboard/DashboardPage.jsx @@ -41,17 +41,19 @@ const DashboardPage = ({ children, ...props }) => (
- {!props.dashboardNotConfigured && props.browserFullScreenCompatible && ( - - )} - {props.currentDashboard && ( + {!props.dashboardNotConfigured && + props.browserFullScreenCompatible && + !props.hideExitFullScreenButton && ( + + )} + {props.currentDashboard && !props.hideExitFullScreenButton && ( + )} + + {validModel && updateButton && ( + + )} + + {validModel && saveButton && ( + + )} + + {validModel && deleteButton && ( + + )} + + {!validModel && ( + + )} + + {validModel && editButton && ( + + + + )} +
+
+
+ + + + ); + } +} + +export default connect('httpClient', {})(MELCloudDeviceBox); diff --git a/front/src/routes/integration/all/melcloud/MELCloudPage.jsx b/front/src/routes/integration/all/melcloud/MELCloudPage.jsx new file mode 100644 index 0000000000..1d2e645b51 --- /dev/null +++ b/front/src/routes/integration/all/melcloud/MELCloudPage.jsx @@ -0,0 +1,60 @@ +import { Text } from 'preact-i18n'; +import { Link } from 'preact-router/match'; + +const MELCloudPage = ({ children }) => ( +
+
+
+
+
+
+

+ +

+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ +
{children}
+
+
+
+
+
+); + +export default MELCloudPage; diff --git a/front/src/routes/integration/all/melcloud/device-page/DeviceTab.jsx b/front/src/routes/integration/all/melcloud/device-page/DeviceTab.jsx new file mode 100644 index 0000000000..b283cfb6ed --- /dev/null +++ b/front/src/routes/integration/all/melcloud/device-page/DeviceTab.jsx @@ -0,0 +1,132 @@ +import { Text, Localizer } from 'preact-i18n'; +import cx from 'classnames'; + +import EmptyState from './EmptyState'; +import { RequestStatus } from '../../../../../utils/consts'; +import style from './style.css'; +import CardFilter from '../../../../../components/layout/CardFilter'; +import debounce from 'debounce'; +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import MELCloudDeviceBox from '../MELCloudDeviceBox'; + +class DeviceTab extends Component { + constructor(props) { + super(props); + this.debouncedSearch = debounce(this.search, 200).bind(this); + } + + componentWillMount() { + this.getDevices(); + this.getHouses(); + } + + async getDevices() { + this.setState({ + getMelCloudStatus: RequestStatus.Getting + }); + try { + const options = { + order_dir: this.state.orderDir || 'asc' + }; + if (this.state.search && this.state.search.length) { + options.search = this.state.search; + } + + const melcloudDevices = await this.props.httpClient.get('/api/v1/service/melcloud/device', options); + this.setState({ + melcloudDevices, + getMelCloudStatus: RequestStatus.Success + }); + } catch (e) { + this.setState({ + getMelCloudStatus: e.message + }); + } + } + + async getHouses() { + this.setState({ + housesGetStatus: RequestStatus.Getting + }); + try { + const params = { + expand: 'rooms' + }; + const housesWithRooms = await this.props.httpClient.get(`/api/v1/house`, params); + this.setState({ + housesWithRooms, + housesGetStatus: RequestStatus.Success + }); + } catch (e) { + this.setState({ + housesGetStatus: RequestStatus.Error + }); + } + } + + async search(e) { + await this.setState({ + search: e.target.value + }); + this.getDevices(); + } + async changeOrderDir(e) { + await this.setState({ + orderDir: e.target.value + }); + this.getDevices(); + } + + render({}, { orderDir, search, getMELCloudStatus, melcloudDevices, housesWithRooms }) { + return ( +
+
+

+ +

+
+ + } + /> + +
+
+
+
+
+
+
+ {melcloudDevices && + melcloudDevices.length > 0 && + melcloudDevices.map((device, index) => ( + + ))} + {!melcloudDevices || (melcloudDevices.length === 0 && )} +
+
+
+
+
+ ); + } +} + +export default connect('httpClient', {})(DeviceTab); diff --git a/front/src/routes/integration/all/melcloud/device-page/EmptyState.jsx b/front/src/routes/integration/all/melcloud/device-page/EmptyState.jsx new file mode 100644 index 0000000000..247c23d83d --- /dev/null +++ b/front/src/routes/integration/all/melcloud/device-page/EmptyState.jsx @@ -0,0 +1,23 @@ +import { Text } from 'preact-i18n'; +import { Link } from 'preact-router/match'; +import cx from 'classnames'; +import style from './style.css'; + +const EmptyState = () => ( +
+
+ + +
+ + + + +
+
+
+); + +export default EmptyState; diff --git a/front/src/routes/integration/all/melcloud/device-page/index.js b/front/src/routes/integration/all/melcloud/device-page/index.js new file mode 100644 index 0000000000..2686847cbf --- /dev/null +++ b/front/src/routes/integration/all/melcloud/device-page/index.js @@ -0,0 +1,16 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import DeviceTab from './DeviceTab'; +import MELCloudPage from '../MELCloudPage'; + +class DevicePage extends Component { + render(props, {}) { + return ( + + + + ); + } +} + +export default connect('user', {})(DevicePage); diff --git a/front/src/routes/integration/all/melcloud/device-page/style.css b/front/src/routes/integration/all/melcloud/device-page/style.css new file mode 100644 index 0000000000..d395f42e21 --- /dev/null +++ b/front/src/routes/integration/all/melcloud/device-page/style.css @@ -0,0 +1,7 @@ +.emptyStateDivBox { + margin-top: 35px; +} + +.melcloudListBody { + min-height: 200px +} diff --git a/front/src/routes/integration/all/melcloud/discover-page/DiscoverTab.jsx b/front/src/routes/integration/all/melcloud/discover-page/DiscoverTab.jsx new file mode 100644 index 0000000000..f654404ec1 --- /dev/null +++ b/front/src/routes/integration/all/melcloud/discover-page/DiscoverTab.jsx @@ -0,0 +1,116 @@ +import { Text } from 'preact-i18n'; +import { Link } from 'preact-router/match'; +import cx from 'classnames'; + +import EmptyState from './EmptyState'; +import style from './style.css'; +import MELCloudDeviceBox from '../MELCloudDeviceBox'; +import { connect } from 'unistore/preact'; +import { Component } from 'preact'; +import { RequestStatus } from '../../../../../utils/consts'; + +class DiscoverTab extends Component { + async componentWillMount() { + this.getDiscoveredDevices(); + this.getHouses(); + } + + async getHouses() { + this.setState({ + housesGetStatus: RequestStatus.Getting + }); + try { + const params = { + expand: 'rooms' + }; + const housesWithRooms = await this.props.httpClient.get(`/api/v1/house`, params); + this.setState({ + housesWithRooms, + housesGetStatus: RequestStatus.Success + }); + } catch (e) { + this.setState({ + housesGetStatus: RequestStatus.Error + }); + } + } + + async getDiscoveredDevices() { + this.setState({ + loading: true + }); + try { + const discoveredDevices = await this.props.httpClient.get('/api/v1/service/melcloud/discover'); + this.setState({ + discoveredDevices, + loading: false, + errorLoading: false + }); + } catch (e) { + this.setState({ + loading: false, + errorLoading: true + }); + } + } + + render(props, { loading, errorLoading, discoveredDevices, housesWithRooms }) { + return ( +
+
+

+ +

+
+ +
+
+
+
+ +
+
+
+
+ {errorLoading && ( +

+ + + + +

+ )} +
+ {discoveredDevices && + discoveredDevices.map((device, index) => ( + + ))} + {!discoveredDevices || (discoveredDevices.length === 0 && )} +
+
+
+
+
+ ); + } +} + +export default connect('httpClient', {})(DiscoverTab); diff --git a/front/src/routes/integration/all/melcloud/discover-page/EmptyState.jsx b/front/src/routes/integration/all/melcloud/discover-page/EmptyState.jsx new file mode 100644 index 0000000000..c09b05815c --- /dev/null +++ b/front/src/routes/integration/all/melcloud/discover-page/EmptyState.jsx @@ -0,0 +1,13 @@ +import { MarkupText } from 'preact-i18n'; +import cx from 'classnames'; +import style from './style.css'; + +const EmptyState = ({}) => ( +
+
+ +
+
+); + +export default EmptyState; diff --git a/front/src/routes/integration/all/melcloud/discover-page/index.js b/front/src/routes/integration/all/melcloud/discover-page/index.js new file mode 100644 index 0000000000..6ff86d797d --- /dev/null +++ b/front/src/routes/integration/all/melcloud/discover-page/index.js @@ -0,0 +1,16 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import DiscoverTab from './DiscoverTab'; +import MELCloudPage from '../MELCloudPage'; + +class MELCloudDiscoverPage extends Component { + render(props) { + return ( + + + + ); + } +} + +export default connect('user', {})(MELCloudDiscoverPage); diff --git a/front/src/routes/integration/all/melcloud/discover-page/style.css b/front/src/routes/integration/all/melcloud/discover-page/style.css new file mode 100644 index 0000000000..72c69610b4 --- /dev/null +++ b/front/src/routes/integration/all/melcloud/discover-page/style.css @@ -0,0 +1,7 @@ +.emptyStateDivBox { + margin-top: 89px; +} + +.melcloudListBody { + min-height: 200px; +} diff --git a/front/src/routes/integration/all/melcloud/edit-page/index.js b/front/src/routes/integration/all/melcloud/edit-page/index.js new file mode 100644 index 0000000000..a50282bd45 --- /dev/null +++ b/front/src/routes/integration/all/melcloud/edit-page/index.js @@ -0,0 +1,21 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import MELCloudPage from '../MELCloudPage'; +import UpdateDevice from '../../../../../components/device'; + +class EditMELCloudDevice extends Component { + render(props, {}) { + return ( + + + + ); + } +} + +export default connect('user,session,httpClient,currentIntegration,houses', {})(EditMELCloudDevice); diff --git a/front/src/routes/integration/all/melcloud/setup-page/SetupTab.jsx b/front/src/routes/integration/all/melcloud/setup-page/SetupTab.jsx new file mode 100644 index 0000000000..4c28663a5b --- /dev/null +++ b/front/src/routes/integration/all/melcloud/setup-page/SetupTab.jsx @@ -0,0 +1,173 @@ +import { Text, Localizer, MarkupText } from 'preact-i18n'; +import cx from 'classnames'; + +import { RequestStatus } from '../../../../../utils/consts'; +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; + +class SetupTab extends Component { + showPasswordTimer = null; + + componentWillMount() { + this.getConfiguration(); + } + + async getConfiguration() { + let melCloudUsername = ''; + let melCloudPassword = ''; + + this.setState({ + melcloudGetSettingsStatus: RequestStatus.Getting, + melCloudUsername, + melCloudPassword + }); + try { + const { value: username } = await this.props.httpClient.get( + '/api/v1/service/melcloud/variable/MELCLOUD_USERNAME' + ); + melCloudUsername = username; + + const { value: password } = await this.props.httpClient.get( + '/api/v1/service/melcloud/variable/MELCLOUD_PASSWORD' + ); + melCloudPassword = password; + + this.setState({ + melcloudGetSettingsStatus: RequestStatus.Success, + melCloudUsername, + melCloudPassword + }); + } catch (e) { + this.setState({ + melcloudGetSettingsStatus: RequestStatus.Error + }); + } + } + + async saveConfiguration(e) { + e.preventDefault(); + this.setState({ + melcloudSaveSettingsStatus: RequestStatus.Getting + }); + try { + await this.props.httpClient.post('/api/v1/service/melcloud/variable/MELCLOUD_USERNAME', { + value: this.state.melCloudUsername.trim() + }); + + await this.props.httpClient.post('/api/v1/service/melcloud/variable/MELCLOUD_PASSWORD', { + value: this.state.melCloudPassword.trim() + }); + + // start service + await this.props.httpClient.post('/api/v1/service/melcloud/start'); + this.setState({ + melcloudSaveSettingsStatus: RequestStatus.Success + }); + } catch (e) { + this.setState({ + melcloudSaveSettingsStatus: RequestStatus.Error + }); + } + } + + updateConfiguration(e) { + this.setState({ + [e.target.name]: e.target.value + }); + } + + togglePassword = () => { + const { showPassword } = this.state; + + if (this.showPasswordTimer) { + clearTimeout(this.showPasswordTimer); + this.showPasswordTimer = null; + } + + this.setState({ showPassword: !showPassword }); + + if (!showPassword) { + this.showPasswordTimer = setTimeout(() => this.setState({ showPassword: false }), 5000); + } + }; + + render(props, state) { + return ( +
+
+

+ +

+
+
+
+
+
+

+ +

+ +
+
+ + + } + value={state.melCloudUsername} + class="form-control" + onInput={this.updateConfiguration.bind(this)} + /> + +
+ +
+ +
+ + } + value={state.melCloudPassword} + className="form-control" + onInput={this.updateConfiguration.bind(this)} + /> + + + + +
+
+ +
+
+ +
+
+ +
+
+
+
+ ); + } +} + +export default connect('httpClient', {})(SetupTab); diff --git a/front/src/routes/integration/all/melcloud/setup-page/index.js b/front/src/routes/integration/all/melcloud/setup-page/index.js new file mode 100644 index 0000000000..f33cfaebe6 --- /dev/null +++ b/front/src/routes/integration/all/melcloud/setup-page/index.js @@ -0,0 +1,16 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import SetupTab from './SetupTab'; +import MELCloudPage from '../MELCloudPage'; + +class MELCloudSetupPage extends Component { + render(props, {}) { + return ( + + + + ); + } +} + +export default connect('user', {})(MELCloudSetupPage); diff --git a/server/services/index.js b/server/services/index.js index f8943cd03d..c335d2850e 100644 --- a/server/services/index.js +++ b/server/services/index.js @@ -21,3 +21,4 @@ module.exports.broadlink = require('./broadlink'); module.exports['lan-manager'] = require('./lan-manager'); module.exports['nextcloud-talk'] = require('./nextcloud-talk'); module.exports.tuya = require('./tuya'); +module.exports.melcloud = require('./melcloud'); diff --git a/server/services/melcloud/api/melcloud.controller.js b/server/services/melcloud/api/melcloud.controller.js new file mode 100644 index 0000000000..13c5cc9b0c --- /dev/null +++ b/server/services/melcloud/api/melcloud.controller.js @@ -0,0 +1,20 @@ +const asyncMiddleware = require('../../../api/middlewares/asyncMiddleware'); + +module.exports = function MELCloudController(melCloudManager) { + /** + * @api {get} /api/v1/service/melcloud/discover Retrieve MELCloud devices from cloud. + * @apiName discover + * @apiGroup MELCloud + */ + async function discover(req, res) { + const devices = await melCloudManager.discoverDevices(); + res.json(devices); + } + + return { + 'get /api/v1/service/melcloud/discover': { + authenticated: true, + controller: asyncMiddleware(discover), + }, + }; +}; diff --git a/server/services/melcloud/index.js b/server/services/melcloud/index.js new file mode 100644 index 0000000000..ca7d59fc64 --- /dev/null +++ b/server/services/melcloud/index.js @@ -0,0 +1,61 @@ +const logger = require('../../utils/logger'); +const melCloudController = require('./api/melcloud.controller'); + +const MELCloudHandler = require('./lib'); +const { STATUS, MELCLOUD_ENDPOINT } = require('./lib/utils/melcloud.constants'); + +module.exports = function MELCloudService(gladys, serviceId) { + const axios = require('axios'); + + const client = axios.create({ + baseURL: MELCLOUD_ENDPOINT, + timeout: 5000, + headers: { + 'Content-Type': 'application/json', + }, + }); + + const melCloudHandler = new MELCloudHandler(gladys, serviceId, client); + + /** + * @public + * @description This function starts service. + * @example + * gladys.services.melcloud.start(); + */ + async function start() { + logger.info('Starting MELCloud service', serviceId); + await melCloudHandler.init(); + await melCloudHandler.loadDevices(); + } + + /** + * @public + * @description This function stops the service. + * @example + * gladys.services.melcloud.stop(); + */ + async function stop() { + logger.info('Stopping MELCloud service'); + await melCloudHandler.disconnect(); + } + + /** + * @public + * @description Test if MELCloud is running. + * @returns {Promise} Returns true if MELCloud is used. + * @example + * const used = await gladys.services.melcloud.isUsed(); + */ + async function isUsed() { + return melCloudHandler.status === STATUS.CONNECTED && melCloudHandler.connector !== null; + } + + return Object.freeze({ + start, + stop, + isUsed, + device: melCloudHandler, + controllers: melCloudController(melCloudHandler), + }); +}; diff --git a/server/services/melcloud/lib/device/air-to-air.device.js b/server/services/melcloud/lib/device/air-to-air.device.js new file mode 100644 index 0000000000..d26a73f41d --- /dev/null +++ b/server/services/melcloud/lib/device/air-to-air.device.js @@ -0,0 +1,125 @@ +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES, AC_MODE } = require('../../../../utils/constants'); + +/** + * @description Get Gladys device features from MELCloud device. + * @param {string} externalId - External ID of the Gladys device. + * @param {object} melCloudDevice - MELCloud device. + * @returns {Array} Array of Gladys device features. + * @example + * getGladysDeviceFeatures('melcloud:123456789:1', melCloudDevice); + */ +function getGladysDeviceFeatures(externalId, melCloudDevice) { + return [ + { + name: 'Power', + external_id: `${externalId}:power`, + selector: `${externalId}:power`, + read_only: false, + has_feedback: true, + min: 0, + max: 1, + category: DEVICE_FEATURE_CATEGORIES.AIR_CONDITIONING, + type: DEVICE_FEATURE_TYPES.AIR_CONDITIONING.BINARY, + }, + { + name: 'Mode', + external_id: `${externalId}:mode`, + selector: `${externalId}:mode`, + read_only: false, + has_feedback: true, + min: 0, + max: 1, + category: DEVICE_FEATURE_CATEGORIES.AIR_CONDITIONING, + type: DEVICE_FEATURE_TYPES.AIR_CONDITIONING.MODE, + }, + { + name: 'Temperature', + external_id: `${externalId}:temperature`, + selector: `${externalId}:temperature`, + read_only: false, + has_feedback: true, + min: melCloudDevice.MinTemperature, + max: melCloudDevice.MaxTemperature, + category: DEVICE_FEATURE_CATEGORIES.AIR_CONDITIONING, + type: DEVICE_FEATURE_TYPES.AIR_CONDITIONING.TARGET_TEMPERATURE, + }, + ]; +} + +const modesMELCloudToGladys = { + 1: AC_MODE.HEATING, + 2: AC_MODE.DRYING, + 3: AC_MODE.COOLING, + 7: AC_MODE.FAN, + 8: AC_MODE.AUTO, +}; + +const modesGaldysToMELCloud = { + [AC_MODE.HEATING]: 1, + [AC_MODE.DRYING]: 2, + [AC_MODE.COOLING]: 3, + [AC_MODE.FAN]: 7, + [AC_MODE.AUTO]: 8, +}; + +/** + * @description Transform value from MELCloud to Gladys. + * @param {object} deviceFeature - Gladys device feature. + * @param {object} values - MELCloud values. + * @returns {number} Value. + * @example + * transfromValueFromMELCloud(deviceFeature, values); + */ +function transfromValueFromMELCloud(deviceFeature, values) { + const [, , code] = deviceFeature.external_id.split(':'); + + switch (code) { + case 'power': + return values.Power ? 1 : 0; + case 'mode': + return modesMELCloudToGladys[values.OperationMode]; + case 'temperature': + return values.SetTemperature; + default: + return null; + } +} + +/** + * @description Transform value from Gladys to MELCloud. + * @param {object} deviceFeature - Gladys device feature. + * @param {number} value - Gladys Value. + * @returns {object} Value. + * @example + * transfromValueFromGladys(deviceFeature, value); + */ +function transfromValueFromGladys(deviceFeature, value) { + const [, , code] = deviceFeature.external_id.split(':'); + + switch (code) { + case 'power': + return { + EffectiveFlags: 1, + Power: value === 1, + }; + case 'mode': + return { + EffectiveFlags: 6, + OperationMode: modesGaldysToMELCloud[value], + }; + case 'temperature': + return { + EffectiveFlags: 4, + SetTemperature: value, + }; + + default: + return null; + } +} + +module.exports = { + getGladysDeviceFeatures, + transfromValueFromMELCloud, + transfromValueFromGladys, +}; diff --git a/server/services/melcloud/lib/device/melcloud.convertDevice.js b/server/services/melcloud/lib/device/melcloud.convertDevice.js new file mode 100644 index 0000000000..05e1338d43 --- /dev/null +++ b/server/services/melcloud/lib/device/melcloud.convertDevice.js @@ -0,0 +1,48 @@ +const { DEVICE_POLL_FREQUENCIES } = require('../../../../utils/constants'); +const { getGladysDeviceFeatures } = require('./air-to-air.device'); + +/** + * @description Transform MELCloud device to Gladys device. + * @param {object} melCloudDevice - MELCloud device. + * @returns {object} Gladys device. + * @example + * convertDevice({ ... }); + */ +function convertDevice(melCloudDevice) { + const externalId = `melcloud:${melCloudDevice.DeviceID}`; + + const gladysDevice = { + name: melCloudDevice.DeviceName, + features: [], + external_id: externalId, + selector: externalId, + model: melCloudDevice.Device.Units[0].Model, + poll_frequency: DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS, + should_poll: true, + params: [ + { + name: 'buildingID', + value: melCloudDevice.BuildingID, + }, + ], + }; + + switch (melCloudDevice.Device.DeviceType) { + case 0: // Air-To-Air + gladysDevice.features = getGladysDeviceFeatures(externalId, melCloudDevice); + break; + case 1: // Air-To-Water + break; + case 3: // Energy-Recovery-Ventilation + break; + default: + // Unknown device type + break; + } + + return gladysDevice; +} + +module.exports = { + convertDevice, +}; diff --git a/server/services/melcloud/lib/index.js b/server/services/melcloud/lib/index.js new file mode 100644 index 0000000000..6eb1a1bb82 --- /dev/null +++ b/server/services/melcloud/lib/index.js @@ -0,0 +1,32 @@ +const { init } = require('./melcloud.init'); +const { connect } = require('./melcloud.connect'); +const { disconnect } = require('./melcloud.disconnect'); +const { getConfiguration } = require('./melcloud.getConfiguration'); +const { saveConfiguration } = require('./melcloud.saveConfiguration'); +const { discoverDevices } = require('./melcloud.discoverDevices'); +const { loadDevices } = require('./melcloud.loadDevices'); +const { setValue } = require('./melcloud.setValue'); +const { poll } = require('./melcloud.poll'); + +const { STATUS } = require('./utils/melcloud.constants'); + +const MELCloudHandler = function MELCloudHandler(gladys, serviceId, client) { + this.gladys = gladys; + this.serviceId = serviceId; + this.client = client; + + this.contextKey = null; + this.status = STATUS.NOT_INITIALIZED; +}; + +MELCloudHandler.prototype.init = init; +MELCloudHandler.prototype.connect = connect; +MELCloudHandler.prototype.disconnect = disconnect; +MELCloudHandler.prototype.getConfiguration = getConfiguration; +MELCloudHandler.prototype.saveConfiguration = saveConfiguration; +MELCloudHandler.prototype.discoverDevices = discoverDevices; +MELCloudHandler.prototype.loadDevices = loadDevices; +MELCloudHandler.prototype.setValue = setValue; +MELCloudHandler.prototype.poll = poll; + +module.exports = MELCloudHandler; diff --git a/server/services/melcloud/lib/melcloud.connect.js b/server/services/melcloud/lib/melcloud.connect.js new file mode 100644 index 0000000000..1a9bb7648d --- /dev/null +++ b/server/services/melcloud/lib/melcloud.connect.js @@ -0,0 +1,59 @@ +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); +const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../utils/constants'); + +const { STATUS } = require('./utils/melcloud.constants'); + +/** + * @description Connect to MELCloud. + * @param {object} configuration - MELCloud configuration properties. + * @example + * connect({baseUrl, accessKey, secretKey}); + */ +async function connect(configuration) { + const { username, password } = configuration; + + if (!username || !password) { + this.status = STATUS.NOT_INITIALIZED; + throw new ServiceNotConfiguredError('MELCloud is not configured.'); + } + + this.status = STATUS.CONNECTING; + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: STATUS.CONNECTING }, + }); + + logger.debug('Connecting to MELCloud...'); + + try { + const { data: response } = await this.client.post('Login/ClientLogin', { + Email: username, + Password: password, + Language: 0, + AppVersion: '1.19.1.1', + Persist: true, + CaptchaResponse: null, + }); + if (!response.ErrorId) { + this.contextKey = response.LoginData.ContextKey; + this.status = STATUS.CONNECTED; + logger.debug('Connected to Tuya'); + } else { + this.status = STATUS.ERROR; + logger.error('Error connecting to Tuya:', response.ErrorMessage); + } + } catch (e) { + this.status = STATUS.ERROR; + logger.error('Error connecting to Tuya:', e); + } + + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: this.status }, + }); +} + +module.exports = { + connect, +}; diff --git a/server/services/melcloud/lib/melcloud.disconnect.js b/server/services/melcloud/lib/melcloud.disconnect.js new file mode 100644 index 0000000000..15aee5a395 --- /dev/null +++ b/server/services/melcloud/lib/melcloud.disconnect.js @@ -0,0 +1,17 @@ +const logger = require('../../../utils/logger'); +const { STATUS } = require('./utils/melcloud.constants'); + +/** + * @description Disconnects service and dependencies. + * @example + * disconnect(); + */ +function disconnect() { + logger.debug('Disconnecting from MELCLoud...'); + this.contextKey = null; + this.status = STATUS.NOT_INITIALIZED; +} + +module.exports = { + disconnect, +}; diff --git a/server/services/melcloud/lib/melcloud.discoverDevices.js b/server/services/melcloud/lib/melcloud.discoverDevices.js new file mode 100644 index 0000000000..5fb7fefa92 --- /dev/null +++ b/server/services/melcloud/lib/melcloud.discoverDevices.js @@ -0,0 +1,63 @@ +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); +const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../utils/constants'); + +const { STATUS } = require('./utils/melcloud.constants'); +const { convertDevice } = require('./device/melcloud.convertDevice'); + +/** + * @description Discover MELCloud cloud devices. + * @returns {Promise} List of discovered devices;. + * @example + * await discoverDevices(); + */ +async function discoverDevices() { + logger.debug('Looking for MELCloud devices...'); + if (this.status !== STATUS.CONNECTED) { + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: this.status }, + }); + throw new ServiceNotConfiguredError('Unable to discover MELCloud devices until service is not well configured'); + } + + // Reset already discovered devices + this.discoveredDevices = []; + this.status = STATUS.DISCOVERING_DEVICES; + + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: this.status }, + }); + + let devices = []; + try { + devices = await this.loadDevices(); + logger.info(`${devices.length} MELCloud devices found`); + } catch (e) { + logger.error('Unable to load MELCloud devices', e); + } + + this.discoveredDevices = devices + .map((device) => ({ + ...convertDevice(device), + service_id: this.serviceId, + })) + .filter((device) => { + const existInGladys = this.gladys.stateManager.get('deviceByExternalId', device.external_id); + return existInGladys === null; + }); + + this.status = STATUS.CONNECTED; + + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: this.status }, + }); + + return this.discoveredDevices; +} + +module.exports = { + discoverDevices, +}; diff --git a/server/services/melcloud/lib/melcloud.getConfiguration.js b/server/services/melcloud/lib/melcloud.getConfiguration.js new file mode 100644 index 0000000000..ccb94da230 --- /dev/null +++ b/server/services/melcloud/lib/melcloud.getConfiguration.js @@ -0,0 +1,24 @@ +const logger = require('../../../utils/logger'); + +const { GLADYS_VARIABLES } = require('./utils/melcloud.constants'); + +/** + * @description Loads MELCloud stored configuration. + * @returns {Promise} MELCloud configuration. + * @example + * await getConfiguration(); + */ +async function getConfiguration() { + logger.debug('Loading MELCloud configuration...'); + const username = await this.gladys.variable.getValue(GLADYS_VARIABLES.USERNAME, this.serviceId); + const password = await this.gladys.variable.getValue(GLADYS_VARIABLES.PASSWORD, this.serviceId); + + return { + username, + password, + }; +} + +module.exports = { + getConfiguration, +}; diff --git a/server/services/melcloud/lib/melcloud.init.js b/server/services/melcloud/lib/melcloud.init.js new file mode 100644 index 0000000000..a4cee62c93 --- /dev/null +++ b/server/services/melcloud/lib/melcloud.init.js @@ -0,0 +1,13 @@ +/** + * @description Initialize service with properties and connect to devices. + * @example + * await init(); + */ +async function init() { + const configuration = await this.getConfiguration(); + await this.connect(configuration); +} + +module.exports = { + init, +}; diff --git a/server/services/melcloud/lib/melcloud.loadDevices.js b/server/services/melcloud/lib/melcloud.loadDevices.js new file mode 100644 index 0000000000..87f488acfb --- /dev/null +++ b/server/services/melcloud/lib/melcloud.loadDevices.js @@ -0,0 +1,41 @@ +const logger = require('../../../utils/logger'); +const { MELCLOUD_ENDPOINT } = require('./utils/melcloud.constants'); + +/** + * @description Discover MELCloud devices. + * @returns {Promise} List of discovered devices. + * @example + * await loadDevices(); + */ +async function loadDevices() { + const { data: response } = await this.client.get(`${MELCLOUD_ENDPOINT}/User/ListDevices`, { + headers: { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:73.0) ', + Accept: 'application/json, text/javascript, */*; q=0.01', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br', + 'X-MitsContextKey': this.contextKey, + 'X-Requested-With': 'XMLHttpRequest', + Cookie: 'policyaccepted=true', + }, + }); + + const devices = []; + + response.forEach((house) => { + devices.push(...house.Structure.Devices); + house.Structure.Areas.forEach((area) => devices.push(...area.Devices)); + house.Structure.Floors.forEach((floor) => { + devices.push(...floor.Devices); + floor.Areas.forEach((area) => devices.push(...area.Devices)); + }); + }); + + logger.debug(`${devices.length} MELCloud devices loaded`); + + return devices; +} + +module.exports = { + loadDevices, +}; diff --git a/server/services/melcloud/lib/melcloud.poll.js b/server/services/melcloud/lib/melcloud.poll.js new file mode 100644 index 0000000000..a08bd20ed0 --- /dev/null +++ b/server/services/melcloud/lib/melcloud.poll.js @@ -0,0 +1,55 @@ +const { BadParameters } = require('../../../utils/coreErrors'); +const { EVENTS } = require('../../../utils/constants'); +const { transfromValueFromMELCloud } = require('./device/air-to-air.device'); + +/** + * + * @description Poll values of an Tuya device. + * @param {object} device - The device to poll. + * @returns {Promise} Promise of nothing. + * @example + * poll(device); + */ +async function poll(device) { + const externalId = device.external_id; + const [prefix, topic] = device.external_id.split(':'); + + if (prefix !== 'melcloud') { + throw new BadParameters(`MELCloud device external_id is invalid: "${externalId}" should starts with "melcloud:"`); + } + if (!topic || topic.length === 0) { + throw new BadParameters(`MELCloud device external_id is invalid: "${externalId}" have no network indicator`); + } + + const buildingId = device.params.find((param) => param.name === 'buildingID').value; + + const { data: response } = await this.client.get('/Device/Get', { + params: { id: topic, buildingID: buildingId }, + headers: { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:73.0) ', + Accept: 'application/json, text/javascript, */*; q=0.01', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br', + 'X-MitsContextKey': this.contextKey, + 'X-Requested-With': 'XMLHttpRequest', + Cookie: 'policyaccepted=true', + }, + }); + + device.features.forEach((deviceFeature) => { + const value = transfromValueFromMELCloud(deviceFeature, response); + + if (deviceFeature.last_value !== value) { + if (value !== null && value !== undefined) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: deviceFeature.external_id, + state: value, + }); + } + } + }); +} + +module.exports = { + poll, +}; diff --git a/server/services/melcloud/lib/melcloud.saveConfiguration.js b/server/services/melcloud/lib/melcloud.saveConfiguration.js new file mode 100644 index 0000000000..45a552560a --- /dev/null +++ b/server/services/melcloud/lib/melcloud.saveConfiguration.js @@ -0,0 +1,23 @@ +const logger = require('../../../utils/logger'); + +const { GLADYS_VARIABLES } = require('./utils/melcloud.constants'); + +/** + * @description Save MELCloud configuration. + * @param {object} configuration - Configuration to save. + * @returns {Promise} MELCloud configuration. + * @example + * await saveConfiguration({ username: '...', password: '...'}); + */ +async function saveConfiguration(configuration) { + logger.debug('Saving MELCloud configuration...'); + const { username, password } = configuration; + await this.gladys.variable.setValue(GLADYS_VARIABLES.USERNAME, username, this.serviceId); + await this.gladys.variable.setValue(GLADYS_VARIABLES.PASSWORD, password, this.serviceId); + + return configuration; +} + +module.exports = { + saveConfiguration, +}; diff --git a/server/services/melcloud/lib/melcloud.setValue.js b/server/services/melcloud/lib/melcloud.setValue.js new file mode 100644 index 0000000000..a401cf6c71 --- /dev/null +++ b/server/services/melcloud/lib/melcloud.setValue.js @@ -0,0 +1,62 @@ +const { BadParameters } = require('../../../utils/coreErrors'); +const { transfromValueFromGladys } = require('./device/air-to-air.device'); + +/** + * @description Send the new device value over device protocol. + * @param {object} device - Updated Gladys device. + * @param {object} deviceFeature - Updated Gladys device feature. + * @param {string|number} value - The new device feature value. + * @example + * setValue(device, deviceFeature, 0); + */ +async function setValue(device, deviceFeature, value) { + const externalId = deviceFeature.external_id; + const [prefix, topic] = deviceFeature.external_id.split(':'); + if (prefix !== 'melcloud') { + throw new BadParameters(`MELCloud device external_id is invalid: "${externalId}" should starts with "melcloud:"`); + } + if (!topic || topic.length === 0) { + throw new BadParameters(`MELCloud device external_id is invalid: "${externalId}" have no network indicator`); + } + + const buildingId = device.params.find((param) => param.name === 'buildingID').value; + + const { data: response } = await this.client.get('/Device/Get', { + params: { id: topic, buildingID: buildingId }, + headers: { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:73.0) ', + Accept: 'application/json, text/javascript, */*; q=0.01', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br', + 'X-MitsContextKey': this.contextKey, + 'X-Requested-With': 'XMLHttpRequest', + Cookie: 'policyaccepted=true', + }, + }); + + const newValue = transfromValueFromGladys(deviceFeature, value); + + const newDevice = { + ...response, + ...newValue, + ...{ + HasPendingCommand: true, + }, + }; + + await this.client.post('/Device/SetAta', newDevice, { + headers: { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:73.0) ', + Accept: 'application/json, text/javascript, */*; q=0.01', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br', + 'X-MitsContextKey': this.contextKey, + 'X-Requested-With': 'XMLHttpRequest', + Cookie: 'policyaccepted=true', + }, + }); +} + +module.exports = { + setValue, +}; diff --git a/server/services/melcloud/lib/utils/melcloud.constants.js b/server/services/melcloud/lib/utils/melcloud.constants.js new file mode 100644 index 0000000000..2002b9538e --- /dev/null +++ b/server/services/melcloud/lib/utils/melcloud.constants.js @@ -0,0 +1,20 @@ +const GLADYS_VARIABLES = { + USERNAME: 'MELCLOUD_USERNAME', + PASSWORD: 'MELCLOUD_PASSWORD', +}; + +const MELCLOUD_ENDPOINT = 'https://app.melcloud.com/Mitsubishi.Wifi.Client/'; + +const STATUS = { + NOT_INITIALIZED: 'not_initialized', + CONNECTING: 'connecting', + CONNECTED: 'connected', + ERROR: 'error', + DISCOVERING_DEVICES: 'discovering', +}; + +module.exports = { + GLADYS_VARIABLES, + MELCLOUD_ENDPOINT, + STATUS, +}; diff --git a/server/services/melcloud/package-lock.json b/server/services/melcloud/package-lock.json new file mode 100644 index 0000000000..5c20556158 --- /dev/null +++ b/server/services/melcloud/package-lock.json @@ -0,0 +1,115 @@ +{ + "name": "gladys-melcloud", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gladys-melcloud", + "version": "1.0.0", + "cpu": [ + "x64", + "arm", + "arm64" + ], + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "axios": "^1.4.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + } + } +} diff --git a/server/services/melcloud/package.json b/server/services/melcloud/package.json new file mode 100644 index 0000000000..97d5c18a7e --- /dev/null +++ b/server/services/melcloud/package.json @@ -0,0 +1,18 @@ +{ + "name": "gladys-melcloud", + "version": "1.0.0", + "main": "index.js", + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm", + "arm64" + ], + "dependencies": { + "axios": "^1.4.0" + } +} diff --git a/server/test/services/melcloud/index.test.js b/server/test/services/melcloud/index.test.js new file mode 100644 index 0000000000..7fcdd54c8d --- /dev/null +++ b/server/test/services/melcloud/index.test.js @@ -0,0 +1,53 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); +const proxyquire = require('proxyquire').noCallThru(); +const { STATUS } = require('../../../services/melcloud/lib/utils/melcloud.constants'); + +const { assert, fake } = sinon; + +const MELCloudHandlerMock = sinon.stub(); +MELCloudHandlerMock.prototype.init = fake.returns(null); +MELCloudHandlerMock.prototype.loadDevices = fake.returns(null); +MELCloudHandlerMock.prototype.disconnect = fake.returns(null); + +const MELCloudService = proxyquire('../../../services/melcloud/index', { './lib': MELCloudHandlerMock }); + +const gladys = {}; +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +describe('MELCloudService', () => { + const melcloudService = MELCloudService(gladys, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should start service', async () => { + await melcloudService.start(); + assert.calledOnce(melcloudService.device.init); + assert.calledOnce(melcloudService.device.loadDevices); + assert.notCalled(melcloudService.device.disconnect); + }); + + it('should stop service', async () => { + melcloudService.stop(); + assert.notCalled(melcloudService.device.init); + assert.calledOnce(melcloudService.device.disconnect); + }); + + it('isUsed: should return false, service not used', async () => { + const used = await melcloudService.isUsed(); + expect(used).to.equal(false); + }); + + it('isUsed: should return true, service is used', async () => { + melcloudService.device.status = STATUS.CONNECTED; + melcloudService.device.connector = {}; + const used = await melcloudService.isUsed(); + expect(used).to.equal(true); + }); +}); diff --git a/server/test/services/melcloud/lib/controllers/melcloud.controller.test.js b/server/test/services/melcloud/lib/controllers/melcloud.controller.test.js new file mode 100644 index 0000000000..ab3d636a21 --- /dev/null +++ b/server/test/services/melcloud/lib/controllers/melcloud.controller.test.js @@ -0,0 +1,28 @@ +const sinon = require('sinon'); +const MELCloudController = require('../../../../../services/melcloud/api/melcloud.controller'); + +const { assert, fake } = sinon; + +const melcloudManager = { + discoverDevices: fake.resolves([]), +}; + +describe('MELCloudController GET /api/v1/service/melcloud/discover', () => { + let controller; + + beforeEach(() => { + controller = MELCloudController(melcloudManager); + sinon.reset(); + }); + + it('should return discovered devices', async () => { + const req = {}; + const res = { + json: fake.returns([]), + }; + + await controller['get /api/v1/service/melcloud/discover'].controller(req, res); + assert.calledOnce(melcloudManager.discoverDevices); + assert.calledOnce(res.json); + }); +}); diff --git a/server/test/services/melcloud/lib/device/feature/air-to-air.device.test.js b/server/test/services/melcloud/lib/device/feature/air-to-air.device.test.js new file mode 100644 index 0000000000..1c418ab5c3 --- /dev/null +++ b/server/test/services/melcloud/lib/device/feature/air-to-air.device.test.js @@ -0,0 +1,260 @@ +const { expect } = require('chai'); +const { + getGladysDeviceFeatures, + transfromValueFromMELCloud, + transfromValueFromGladys, +} = require('../../../../../../services/melcloud/lib/device/air-to-air.device'); +const { AC_MODE } = require('../../../../../../utils/constants'); + +describe('MELCloud Air to Air device', () => { + it('should return device features', () => { + const result = getGladysDeviceFeatures('melcloud:123456789:1', { MinTemperature: 1, MaxTemperature: 100 }); + expect(result).to.deep.eq([ + { + category: 'air-conditioning', + external_id: 'melcloud:123456789:1:power', + has_feedback: true, + max: 1, + min: 0, + name: 'Power', + read_only: false, + selector: 'melcloud:123456789:1:power', + type: 'binary', + }, + { + category: 'air-conditioning', + external_id: 'melcloud:123456789:1:mode', + has_feedback: true, + max: 1, + min: 0, + name: 'Mode', + read_only: false, + selector: 'melcloud:123456789:1:mode', + type: 'mode', + }, + { + category: 'air-conditioning', + external_id: 'melcloud:123456789:1:temperature', + has_feedback: true, + max: 100, + min: 1, + name: 'Temperature', + read_only: false, + selector: 'melcloud:123456789:1:temperature', + type: 'target-temperature', + }, + ]); + }); + + describe('should transform value from MELCloud', () => { + it('when the power is true', () => { + const result = transfromValueFromMELCloud( + { + external_id: 'melcloud:123456789:power', + }, + { + Power: true, + }, + ); + expect(result).to.eq(1); + }); + it('when the power is false', () => { + const result = transfromValueFromMELCloud( + { + external_id: 'melcloud:123456789:power', + }, + { + Power: false, + }, + ); + expect(result).to.eq(0); + }); + + it('when the mode is heating', () => { + const result = transfromValueFromMELCloud( + { + external_id: 'melcloud:123456789:mode', + }, + { + OperationMode: 1, + }, + ); + expect(result).to.eq(AC_MODE.HEATING); + }); + it('when the mode is drying', () => { + const result = transfromValueFromMELCloud( + { + external_id: 'melcloud:123456789:mode', + }, + { + OperationMode: 2, + }, + ); + expect(result).to.eq(AC_MODE.DRYING); + }); + it('when the mode is cooling', () => { + const result = transfromValueFromMELCloud( + { + external_id: 'melcloud:123456789:mode', + }, + { + OperationMode: 3, + }, + ); + expect(result).to.eq(AC_MODE.COOLING); + }); + it('when the mode is fan', () => { + const result = transfromValueFromMELCloud( + { + external_id: 'melcloud:123456789:mode', + }, + { + OperationMode: 7, + }, + ); + expect(result).to.eq(AC_MODE.FAN); + }); + it('when the mode is auto', () => { + const result = transfromValueFromMELCloud( + { + external_id: 'melcloud:123456789:mode', + }, + { + OperationMode: 8, + }, + ); + expect(result).to.eq(AC_MODE.AUTO); + }); + it('when the temperature is set to 27°', () => { + const result = transfromValueFromMELCloud( + { + external_id: 'melcloud:123456789:temperature', + }, + { + SetTemperature: 27, + }, + ); + expect(result).to.eq(27); + }); + it('when code is unknown', () => { + const result = transfromValueFromMELCloud( + { + external_id: 'melcloud:123456789:unknown', + }, + { + SetTemperature: 27, + }, + ); + expect(result).to.eq(null); + }); + }); + + describe('should transform value from Gladys', () => { + it('when the power is true', () => { + const result = transfromValueFromGladys( + { + external_id: 'melcloud:123456789:power', + }, + 1, + ); + expect(result).to.deep.eq({ + EffectiveFlags: 1, + Power: true, + }); + }); + it('when the power is false', () => { + const result = transfromValueFromGladys( + { + external_id: 'melcloud:123456789:power', + }, + 0, + ); + expect(result).to.deep.eq({ + EffectiveFlags: 1, + Power: false, + }); + }); + + it('when the mode is heating', () => { + const result = transfromValueFromGladys( + { + external_id: 'melcloud:123456789:mode', + }, + AC_MODE.HEATING, + ); + expect(result).to.deep.eq({ + EffectiveFlags: 6, + OperationMode: 1, + }); + }); + it('when the mode is drying', () => { + const result = transfromValueFromGladys( + { + external_id: 'melcloud:123456789:mode', + }, + AC_MODE.DRYING, + ); + expect(result).to.deep.eq({ + EffectiveFlags: 6, + OperationMode: 2, + }); + }); + it('when the mode is cooling', () => { + const result = transfromValueFromGladys( + { + external_id: 'melcloud:123456789:mode', + }, + AC_MODE.COOLING, + ); + expect(result).to.deep.eq({ + EffectiveFlags: 6, + OperationMode: 3, + }); + }); + it('when the mode is fan', () => { + const result = transfromValueFromGladys( + { + external_id: 'melcloud:123456789:mode', + }, + AC_MODE.FAN, + ); + expect(result).to.deep.eq({ + EffectiveFlags: 6, + OperationMode: 7, + }); + }); + it('when the mode is auto', () => { + const result = transfromValueFromGladys( + { + external_id: 'melcloud:123456789:mode', + }, + AC_MODE.AUTO, + ); + expect(result).to.deep.eq({ + EffectiveFlags: 6, + OperationMode: 8, + }); + }); + it('when the temperature is set to 27°', () => { + const result = transfromValueFromGladys( + { + external_id: 'melcloud:123456789:temperature', + }, + 27, + ); + expect(result).to.deep.eq({ + EffectiveFlags: 4, + SetTemperature: 27, + }); + }); + it('when code is unknown', () => { + const result = transfromValueFromGladys( + { + external_id: 'melcloud:123456789:unknown', + }, + 1, + ); + expect(result).to.eq(null); + }); + }); +}); diff --git a/server/test/services/melcloud/lib/device/feature/melcloud.convertDevice.test.js b/server/test/services/melcloud/lib/device/feature/melcloud.convertDevice.test.js new file mode 100644 index 0000000000..fd73a480f3 --- /dev/null +++ b/server/test/services/melcloud/lib/device/feature/melcloud.convertDevice.test.js @@ -0,0 +1,136 @@ +const { expect } = require('chai'); + +const { convertDevice } = require('../../../../../../services/melcloud/lib/device/melcloud.convertDevice'); + +describe('MELCloud convert device', () => { + it('should return converted device air to air', () => { + const result = convertDevice({ + DeviceID: '192919', + DeviceName: 'AirCooler', + Device: { + Units: [ + { + Model: 'MSZ-AP25VG', + }, + ], + DeviceType: 0, + }, + BuildingID: '123456789', + MinTemperature: 1, + MaxTemperature: 100, + }); + expect(result).to.deep.eq({ + external_id: 'melcloud:192919', + features: [ + { + category: 'air-conditioning', + external_id: 'melcloud:192919:power', + has_feedback: true, + max: 1, + min: 0, + name: 'Power', + read_only: false, + selector: 'melcloud:192919:power', + type: 'binary', + }, + { + category: 'air-conditioning', + external_id: 'melcloud:192919:mode', + has_feedback: true, + max: 1, + min: 0, + name: 'Mode', + read_only: false, + selector: 'melcloud:192919:mode', + type: 'mode', + }, + { + category: 'air-conditioning', + external_id: 'melcloud:192919:temperature', + has_feedback: true, + max: 100, + min: 1, + name: 'Temperature', + read_only: false, + selector: 'melcloud:192919:temperature', + type: 'target-temperature', + }, + ], + model: 'MSZ-AP25VG', + name: 'AirCooler', + params: [ + { + name: 'buildingID', + value: '123456789', + }, + ], + poll_frequency: 10000, + selector: 'melcloud:192919', + should_poll: true, + }); + }); + + it('should return converted device air to water', () => { + const result = convertDevice({ + DeviceID: '192919', + DeviceName: 'AirToWater', + Device: { + Units: [ + { + Model: 'MSZ-AP25VG', + }, + ], + DeviceType: 1, + }, + BuildingID: '123456789', + MinTemperature: 1, + MaxTemperature: 100, + }); + expect(result).to.deep.eq({ + external_id: 'melcloud:192919', + features: [], + model: 'MSZ-AP25VG', + name: 'AirToWater', + params: [ + { + name: 'buildingID', + value: '123456789', + }, + ], + poll_frequency: 10000, + selector: 'melcloud:192919', + should_poll: true, + }); + }); + + it('should return converted device energy recovery ventilation', () => { + const result = convertDevice({ + DeviceID: '192919', + DeviceName: 'energy recovery ventilation', + Device: { + Units: [ + { + Model: 'MSZ-AP25VG', + }, + ], + DeviceType: 3, + }, + BuildingID: '123456789', + }); + expect(result).to.deep.eq({ + external_id: 'melcloud:192919', + features: [], + model: 'MSZ-AP25VG', + name: 'energy recovery ventilation', + params: [ + { + name: 'buildingID', + value: '123456789', + }, + ], + poll_frequency: 10000, + selector: 'melcloud:192919', + should_poll: true, + }); + }); +}); diff --git a/server/test/services/melcloud/lib/melcloud.connect.test.js b/server/test/services/melcloud/lib/melcloud.connect.test.js new file mode 100644 index 0000000000..087ef67b83 --- /dev/null +++ b/server/test/services/melcloud/lib/melcloud.connect.test.js @@ -0,0 +1,133 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { assert, fake } = sinon; + +const MELCloudHandler = require('../../../../services/melcloud/lib/index'); +const { STATUS } = require('../../../../services/melcloud/lib/utils/melcloud.constants'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); +const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); + +const gladys = { + event: { + emit: fake.returns(null), + }, +}; + +const client = { + post: fake.resolves({ data: { LoginData: { ContextKey: 'context-key' } } }), +}; + +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +describe('MELCloudHandler.connect', () => { + const melcloudHandler = new MELCloudHandler(gladys, serviceId, client); + + beforeEach(() => { + sinon.reset(); + melcloudHandler.status = 'UNKNOWN'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('no username stored, should fail', async () => { + try { + await melcloudHandler.connect({ + password: 'password', + }); + assert.fail(); + } catch (e) { + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + } + + expect(melcloudHandler.status).to.eq(STATUS.NOT_INITIALIZED); + + assert.notCalled(gladys.event.emit); + assert.notCalled(client.post); + }); + + it('no password stored, should fail', async () => { + try { + await melcloudHandler.connect({ + username: 'username', + }); + assert.fail(); + } catch (e) { + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + } + + expect(melcloudHandler.status).to.eq(STATUS.NOT_INITIALIZED); + + assert.notCalled(gladys.event.emit); + assert.notCalled(client.post); + }); + + it('well connected', async () => { + await melcloudHandler.connect({ + username: 'username', + password: 'password', + }); + + expect(melcloudHandler.status).to.eq(STATUS.CONNECTED); + + assert.calledOnce(client.post); + + assert.callCount(gladys.event.emit, 2); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: STATUS.CONNECTING }, + }); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: STATUS.CONNECTED }, + }); + }); + + it('error while connecting', async () => { + client.post = fake.throws('error'); + + await melcloudHandler.connect({ + username: 'username', + password: 'password', + }); + + expect(melcloudHandler.status).to.eq(STATUS.ERROR); + + assert.calledOnce(client.post); + + assert.callCount(gladys.event.emit, 2); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: STATUS.CONNECTING }, + }); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: STATUS.ERROR }, + }); + }); + + it('invalid username or password while connecting', async () => { + client.post = fake.resolves({ data: { ErrorId: 1000, ErrorMessage: 'error' } }); + + await melcloudHandler.connect({ + username: 'username', + password: 'password', + }); + + expect(melcloudHandler.status).to.eq(STATUS.ERROR); + + assert.calledOnce(client.post); + + assert.callCount(gladys.event.emit, 2); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: STATUS.CONNECTING }, + }); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: STATUS.ERROR }, + }); + }); +}); diff --git a/server/test/services/melcloud/lib/melcloud.disconnect.test.js b/server/test/services/melcloud/lib/melcloud.disconnect.test.js new file mode 100644 index 0000000000..789f0f11d6 --- /dev/null +++ b/server/test/services/melcloud/lib/melcloud.disconnect.test.js @@ -0,0 +1,22 @@ +const { expect } = require('chai'); + +const MELCloudHandler = require('../../../../services/melcloud/lib/index'); +const { STATUS } = require('../../../../services/melcloud/lib/utils/melcloud.constants'); + +const gladys = {}; +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +describe('MELCloudHandler.disconnect', () => { + const melcloudHandler = new MELCloudHandler(gladys, serviceId); + + beforeEach(() => { + melcloudHandler.status = 'UNKNOWN'; + }); + + it('should reset attributes', () => { + melcloudHandler.disconnect(); + + expect(melcloudHandler.status).to.eq(STATUS.NOT_INITIALIZED); + expect(melcloudHandler.contextKey).to.eq(null); + }); +}); diff --git a/server/test/services/melcloud/lib/melcloud.discoverDevices.test.js b/server/test/services/melcloud/lib/melcloud.discoverDevices.test.js new file mode 100644 index 0000000000..a7fcd3c430 --- /dev/null +++ b/server/test/services/melcloud/lib/melcloud.discoverDevices.test.js @@ -0,0 +1,142 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { assert, fake } = sinon; + +const MELCloudHandler = require('../../../../services/melcloud/lib/index'); +const { STATUS } = require('../../../../services/melcloud/lib/utils/melcloud.constants'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); +const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + stateManager: { + get: fake.returns(null), + }, + variable: { + getValue: fake.resolves('APP_ACCOUNT_UID'), + }, +}; +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +const client = { + get: fake.resolves(null), +}; + +describe('MELCloudHandler.discoverDevices', () => { + const melcloudHandler = new MELCloudHandler(gladys, serviceId, client); + + beforeEach(() => { + sinon.reset(); + melcloudHandler.status = STATUS.CONNECTED; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should fail because service is not ready', async () => { + melcloudHandler.status = 'unknown'; + + try { + await melcloudHandler.discoverDevices(); + assert.fail(); + } catch (e) { + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + } + + assert.calledOnce(gladys.event.emit); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: 'unknown' }, + }); + + assert.notCalled(melcloudHandler.client.get); + }); + + it('should fail because device request fails', async () => { + melcloudHandler.client.get = fake.rejects(); + + const devices = await melcloudHandler.discoverDevices(); + expect(devices).to.be.lengthOf(0); + + assert.callCount(gladys.event.emit, 2); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: STATUS.DISCOVERING_DEVICES }, + }); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: STATUS.CONNECTED }, + }); + + assert.calledOnce(melcloudHandler.client.get); + }); + + it('should load devices', async () => { + client.get = fake.resolves({ + data: [ + { + Structure: { + Devices: [ + { + DeviceID: 'uuid', + DeviceName: 'name', + BuildingID: 'building_uuid', + Device: { + DeviceType: 1, + Units: [ + { + Model: 'model', + }, + ], + }, + }, + ], + Areas: [], + Floors: [ + { + Devices: [], + Areas: [], + }, + ], + }, + }, + ], + }); + + const devices = await melcloudHandler.discoverDevices(); + expect(devices).to.deep.eq([ + { + external_id: 'melcloud:uuid', + features: [], + model: 'model', + name: 'name', + params: [ + { + name: 'buildingID', + value: 'building_uuid', + }, + ], + poll_frequency: 10000, + selector: 'melcloud:uuid', + service_id: 'ffa13430-df93-488a-9733-5c540e9558e0', + should_poll: true, + }, + ]); + + assert.callCount(gladys.event.emit, 2); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: STATUS.DISCOVERING_DEVICES }, + }); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: STATUS.CONNECTED }, + }); + + assert.calledOnce(melcloudHandler.client.get); + }); +}); diff --git a/server/test/services/melcloud/lib/melcloud.getConfiguration.test.js b/server/test/services/melcloud/lib/melcloud.getConfiguration.test.js new file mode 100644 index 0000000000..131a6946b5 --- /dev/null +++ b/server/test/services/melcloud/lib/melcloud.getConfiguration.test.js @@ -0,0 +1,45 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { assert } = sinon; + +const MELCloudHandler = require('../../../../services/melcloud/lib/index'); +const { GLADYS_VARIABLES } = require('../../../../services/melcloud/lib/utils/melcloud.constants'); + +const gladys = { + variable: { + getValue: sinon.stub(), + }, +}; +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +describe('MELCloudHandler.getConfiguration', () => { + const melcloudHandler = new MELCloudHandler(gladys, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should load configuration', async () => { + gladys.variable.getValue + .withArgs(GLADYS_VARIABLES.USERNAME, serviceId) + .returns('username') + .withArgs(GLADYS_VARIABLES.PASSWORD, serviceId) + .returns('password'); + + const config = await melcloudHandler.getConfiguration(); + + expect(config).to.deep.eq({ + username: 'username', + password: 'password', + }); + + assert.callCount(gladys.variable.getValue, 2); + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.USERNAME, serviceId); + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.PASSWORD, serviceId); + }); +}); diff --git a/server/test/services/melcloud/lib/melcloud.init.test.js b/server/test/services/melcloud/lib/melcloud.init.test.js new file mode 100644 index 0000000000..f3901c8e12 --- /dev/null +++ b/server/test/services/melcloud/lib/melcloud.init.test.js @@ -0,0 +1,63 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { assert, fake } = sinon; + +const MELCloudHandler = require('../../../../services/melcloud/lib/index'); +const { STATUS, GLADYS_VARIABLES } = require('../../../../services/melcloud/lib/utils/melcloud.constants'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); + +const gladys = { + variable: { + getValue: sinon.stub(), + }, + event: { + emit: fake.returns(null), + }, +}; +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +const client = { + post: fake.resolves({ data: { LoginData: { ContextKey: 'ContextKey' } } }), +}; + +describe('MELCloudHandler.init', () => { + const melcloudHandler = new MELCloudHandler(gladys, serviceId, client); + + beforeEach(() => { + sinon.reset(); + melcloudHandler.status = 'UNKNOWN'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('well initialized', async () => { + gladys.variable.getValue + .withArgs(GLADYS_VARIABLES.USERNAME, serviceId) + .returns('username') + .withArgs(GLADYS_VARIABLES.PASSWORD, serviceId) + .returns('password'); + + await melcloudHandler.init(); + + expect(melcloudHandler.status).to.eq(STATUS.CONNECTED); + + assert.callCount(gladys.variable.getValue, 2); + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.USERNAME, serviceId); + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.PASSWORD, serviceId); + + assert.calledOnce(client.post); + + assert.callCount(gladys.event.emit, 2); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: STATUS.CONNECTING }, + }); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.MELCLOUD.STATUS, + payload: { status: STATUS.CONNECTED }, + }); + }); +}); diff --git a/server/test/services/melcloud/lib/melcloud.loadDevices.test.js b/server/test/services/melcloud/lib/melcloud.loadDevices.test.js new file mode 100644 index 0000000000..2431cb5987 --- /dev/null +++ b/server/test/services/melcloud/lib/melcloud.loadDevices.test.js @@ -0,0 +1,80 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { assert, fake } = sinon; + +const MELCloudHandler = require('../../../../services/melcloud/lib/index'); + +const gladys = { + variable: { + getValue: fake.resolves('APP_ACCOUNT_UID'), + }, +}; +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +const client = { + get: fake.resolves({ + data: [ + { + Structure: { + Devices: [ + { + DeviceID: 'uuid', + DeviceName: 'name', + BuildingID: 'building_uuid', + Device: { + DeviceType: 1, + Units: [ + { + Model: 'model', + }, + ], + }, + }, + ], + Areas: [], + Floors: [ + { + Devices: [], + Areas: [], + }, + ], + }, + }, + ], + }), +}; + +describe('MELCloudHandler.loadDevices', () => { + const melcloudHandler = new MELCloudHandler(gladys, serviceId, client); + + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should load devices', async () => { + const devices = await melcloudHandler.loadDevices(); + + expect(devices).to.deep.eq([ + { + BuildingID: 'building_uuid', + Device: { + DeviceType: 1, + Units: [ + { + Model: 'model', + }, + ], + }, + DeviceID: 'uuid', + DeviceName: 'name', + }, + ]); + + assert.calledOnce(client.get); + }); +}); diff --git a/server/test/services/melcloud/lib/melcloud.poll.test.js b/server/test/services/melcloud/lib/melcloud.poll.test.js new file mode 100644 index 0000000000..8a635ff1b9 --- /dev/null +++ b/server/test/services/melcloud/lib/melcloud.poll.test.js @@ -0,0 +1,115 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { assert, fake } = sinon; + +const MELCloudHandler = require('../../../../services/melcloud/lib/index'); +const { EVENTS } = require('../../../../utils/constants'); + +const { BadParameters } = require('../../../../utils/coreErrors'); + +const gladys = { + variable: { + getValue: sinon.stub(), + }, + event: { + emit: fake.returns(null), + }, +}; +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +const client = { + get: fake.resolves({ + data: { + DeviceID: 'uuid', + DeviceName: 'name', + BuildingID: 'building_uuid', + Power: true, + Device: { + DeviceType: 0, + Units: [ + { + Model: 'model', + }, + ], + }, + }, + }), +}; + +describe('MELCloudHandler.poll', () => { + const melcloudHandler = new MELCloudHandler(gladys, serviceId, client); + + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should throw an error (should starts with "melcloud:")', async () => { + try { + await melcloudHandler.poll({ + external_id: 'test:device', + features: [ + { + external_id: 'melcloud:feature', + category: 'light', + type: 'binary', + }, + ], + }); + } catch (error) { + expect(error).to.be.an.instanceof(BadParameters); + expect(error.message).to.equal( + 'MELCloud device external_id is invalid: "test:device" should starts with "melcloud:"', + ); + } + }); + + it('should throw an error (have no network indicator)', async () => { + try { + await melcloudHandler.poll({ + external_id: 'melcloud', + features: [ + { + external_id: 'melcloud:feature', + category: 'light', + type: 'binary', + }, + ], + }); + } catch (error) { + expect(error).to.be.an.instanceof(BadParameters); + expect(error.message).to.equal('MELCloud device external_id is invalid: "melcloud" have no network indicator'); + } + }); + + it('change state of device feature', async () => { + await melcloudHandler.poll({ + external_id: 'melcloud:device', + features: [ + { + external_id: 'melcloud:feature:power', + category: 'light', + type: 'binary', + }, + ], + params: [ + { + name: 'buildingID', + value: 'building_uuid', + }, + ], + }); + + assert.calledOnce(client.get); + + assert.callCount(gladys.event.emit, 1); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'melcloud:feature:power', + state: 1, + }); + }); +}); diff --git a/server/test/services/melcloud/lib/melcloud.saveConfiguration.test.js b/server/test/services/melcloud/lib/melcloud.saveConfiguration.test.js new file mode 100644 index 0000000000..872e9536a8 --- /dev/null +++ b/server/test/services/melcloud/lib/melcloud.saveConfiguration.test.js @@ -0,0 +1,41 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { assert, fake } = sinon; + +const MELCloudHandler = require('../../../../services/melcloud/lib/index'); +const { GLADYS_VARIABLES } = require('../../../../services/melcloud/lib/utils/melcloud.constants'); + +const gladys = { + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +describe('MELCloudHandler.saveConfiguration', () => { + const melcloudHandler = new MELCloudHandler(gladys, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save configuration', async () => { + const configuration = { + username: 'username', + password: 'password', + }; + + const config = await melcloudHandler.saveConfiguration(configuration); + + expect(config).to.deep.eq(configuration); + + assert.callCount(gladys.variable.setValue, 2); + assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.USERNAME, 'username', serviceId); + assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.PASSWORD, 'password', serviceId); + }); +}); diff --git a/server/test/services/melcloud/lib/melcloud.setValue.test.js b/server/test/services/melcloud/lib/melcloud.setValue.test.js new file mode 100644 index 0000000000..49be1e17f7 --- /dev/null +++ b/server/test/services/melcloud/lib/melcloud.setValue.test.js @@ -0,0 +1,128 @@ +const sinon = require('sinon'); + +const { assert, fake } = sinon; + +const { expect } = require('chai'); +const MELCloudHandler = require('../../../../services/melcloud/lib/index'); +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); +const { BadParameters } = require('../../../../utils/coreErrors'); + +const gladys = { + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +const client = { + get: fake.resolves({ + data: { + DeviceID: 'uuid', + DeviceName: 'name', + BuildingID: 'building_uuid', + Power: true, + Device: { + DeviceType: 0, + Units: [ + { + Model: 'model', + }, + ], + }, + }, + }), + post: fake.resolves({}), +}; + +describe('MELCloudHandler.setValue', () => { + const melcloudHandler = new MELCloudHandler(gladys, serviceId, client); + melcloudHandler.contextKey = 'contextKey'; + + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should throw an error (should starts with "melcloud:")', async () => { + try { + await melcloudHandler.setValue( + {}, + { + external_id: 'test:uuid:switch_0', + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + }, + 1, + ); + } catch (error) { + expect(error).to.be.an.instanceof(BadParameters); + expect(error.message).to.equal( + 'MELCloud device external_id is invalid: "test:uuid:switch_0" should starts with "melcloud:"', + ); + } + }); + + it('should throw an error (have no network indicator)', async () => { + try { + await melcloudHandler.setValue( + {}, + { + external_id: 'melcloud:', + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + }, + 1, + ); + } catch (error) { + expect(error).to.be.an.instanceof(BadParameters); + expect(error.message).to.equal('MELCloud device external_id is invalid: "melcloud:" have no network indicator'); + } + }); + + it('should call melcloud api', async () => { + await melcloudHandler.setValue( + { + params: [ + { + name: 'buildingID', + value: '12345', + }, + ], + }, + { + external_id: 'melcloud:uuid:switch_0', + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + }, + 1, + ); + + assert.calledOnce(client.get); + assert.calledWith( + client.post, + '/Device/SetAta', + { + BuildingID: 'building_uuid', + Device: { DeviceType: 0, Units: [{ Model: 'model' }] }, + DeviceID: 'uuid', + DeviceName: 'name', + HasPendingCommand: true, + Power: true, + }, + { + headers: { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:73.0) ', + Accept: 'application/json, text/javascript, */*; q=0.01', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br', + 'X-MitsContextKey': 'contextKey', + 'X-Requested-With': 'XMLHttpRequest', + Cookie: 'policyaccepted=true', + }, + }, + ); + }); +}); diff --git a/server/utils/constants.js b/server/utils/constants.js index 06d7c44e91..0775f33bc3 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -54,6 +54,8 @@ const AC_MODE = { AUTO: 0, COOLING: 1, HEATING: 2, + DRYING: 3, + FAN: 4, }; const USER_ROLE = { @@ -883,6 +885,10 @@ const WEBSOCKET_MESSAGE_TYPES = { STATUS: 'tuya.status', DISCOVER: 'tuya.discover', }, + MELCLOUD: { + STATUS: 'melcloud.status', + DISCOVER: 'melcloud.discover', + }, }; const DASHBOARD_TYPE = { From 3334ae3bbe68b6d8d8b94c97459ee61fda7ba5f4 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Thu, 28 Sep 2023 15:03:54 +0200 Subject: [PATCH 7/9] Scene: In device.set-value action, binary feature should default to 0 (#1901) --- .../routes/scene/edit-scene/actions/DeviceSetValue.jsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/front/src/routes/scene/edit-scene/actions/DeviceSetValue.jsx b/front/src/routes/scene/edit-scene/actions/DeviceSetValue.jsx index 5422ff01ee..1b97ca2f2a 100644 --- a/front/src/routes/scene/edit-scene/actions/DeviceSetValue.jsx +++ b/front/src/routes/scene/edit-scene/actions/DeviceSetValue.jsx @@ -33,8 +33,13 @@ class DeviceSetValue extends Component { this.props.updateActionProperty(columnIndex, index, 'device_feature', null); } if (deviceFeatureChanged) { - this.props.updateActionProperty(columnIndex, index, 'value', undefined); - this.props.updateActionProperty(columnIndex, index, 'evaluate_value', undefined); + if (deviceFeature.type === DEVICE_FEATURE_TYPES.SWITCH.BINARY) { + this.props.updateActionProperty(columnIndex, index, 'value', 0); + this.props.updateActionProperty(columnIndex, index, 'evaluate_value', undefined); + } else { + this.props.updateActionProperty(columnIndex, index, 'value', undefined); + this.props.updateActionProperty(columnIndex, index, 'evaluate_value', undefined); + } } this.setState({ deviceFeature, device }); }; From 9fe0c9397930e3009c7daf701559de82251bc7c9 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Thu, 28 Sep 2023 15:26:39 +0200 Subject: [PATCH 8/9] MQTT: Add conflict error when creating a device with same selector (#1902) --- front/src/config/i18n/en.json | 2 +- front/src/config/i18n/fr.json | 2 +- .../all/mqtt/device-page/setup/index.js | 19 ++++++++++++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 825a95f34b..f677766589 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -881,7 +881,7 @@ "notFound": "Requested device not found.", "backToList": "Back to device list", "saveError": "Error saving or deleting device", - "saveConflictError": "Conflict: Are you sure all device feature external IDs are unique?", + "saveConflictError": "Conflict: Are you sure all external IDs are unique?", "mostRecentValueAt": "Last value received {{mostRecentValueAt}}.", "noValueReceived": "No value received." }, diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 8acf80059b..16052f2189 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -1009,7 +1009,7 @@ "notFound": "Appareil introuvable.", "backToList": "Retour à la liste des appareils", "saveError": "Erreur lors de l'enregistrement ou de la suppression de l'appareil", - "saveConflictError": "Conflit : êtes-vous sûr que tous les IDs externes des fonctionnalités de l'appareil sont uniques ?", + "saveConflictError": "Conflit : êtes-vous sûr que tous les IDs externes sont uniques ?", "mostRecentValueAt": "Dernière valeur reçue {{mostRecentValueAt}}.", "noValueReceived": "Aucune valeur reçue." }, diff --git a/front/src/routes/integration/all/mqtt/device-page/setup/index.js b/front/src/routes/integration/all/mqtt/device-page/setup/index.js index 11bebc459d..583fa58f5c 100644 --- a/front/src/routes/integration/all/mqtt/device-page/setup/index.js +++ b/front/src/routes/integration/all/mqtt/device-page/setup/index.js @@ -7,6 +7,7 @@ import uuid from 'uuid'; import get from 'get-value'; import update from 'immutability-helper'; import { RequestStatus } from '../../../../../../utils/consts'; +import { slugify } from '../../../../../../../../server/utils/slugify'; import withIntlAsProp from '../../../../../../utils/withIntlAsProp'; import { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } from '../../../../../../../../server/utils/constants'; @@ -147,6 +148,20 @@ class MqttDeviceSetupPage extends Component { loading: true }); try { + // If we are creating a device, we check that the device doesn't already exist + if (!this.state.device.id) { + try { + await this.props.httpClient.get(`/api/v1/device/${slugify(this.state.device.selector)}`); + // if we are here, it means the device already exist + this.setState({ + saveStatus: RequestStatus.ConflictError, + loading: false + }); + return; + } catch (e) { + // If we are here, it's ok, it means the device does not exist yet + } + } const device = await this.props.httpClient.post('/api/v1/device', this.state.device); this.setState({ saveStatus: RequestStatus.Success, @@ -223,10 +238,8 @@ class MqttDeviceSetupPage extends Component { let device; if (!deviceSelector) { - const uniqueId = uuid.v4(); device = { - id: uniqueId, - name: null, + name: '', should_poll: false, external_id: 'mqtt:', service_id: this.props.currentIntegration.id, From f5a6a0dffd808c4476da7988f5490362c67a3278 Mon Sep 17 00:00:00 2001 From: Pochet Romuald Date: Fri, 6 Oct 2023 11:43:26 +0200 Subject: [PATCH 9/9] Device new string state event: better null check (#1894) --- server/lib/device/device.newStateEvent.js | 13 +++-- server/lib/device/device.saveStringState.js | 13 +++-- .../lib/device/device.newStateEvent.test.js | 56 +++++++++++++++++++ 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/server/lib/device/device.newStateEvent.js b/server/lib/device/device.newStateEvent.js index 963f53165f..03674c67f9 100644 --- a/server/lib/device/device.newStateEvent.js +++ b/server/lib/device/device.newStateEvent.js @@ -12,12 +12,15 @@ const logger = require('../../utils/logger'); * newStateEvent({ device_feature_external_id: 'xx', state: 12 }); */ async function newStateEvent(event) { + const deviceFeature = this.stateManager.get('deviceFeatureByExternalId', event.device_feature_external_id); + if (deviceFeature === null) { + throw new NotFoundError(`DeviceFeature ${event.device_feature_external_id} not found`); + } + const device = this.stateManager.get('deviceById', deviceFeature.device_id); + if (device === null) { + throw new NotFoundError(`Device ${deviceFeature.device_id} not found`); + } try { - const deviceFeature = this.stateManager.get('deviceFeatureByExternalId', event.device_feature_external_id); - const device = this.stateManager.get('deviceById', deviceFeature.device_id); - if (deviceFeature === null) { - throw new NotFoundError('DeviceFeature not found'); - } if (event.text) { await this.saveStringState(device, deviceFeature, event.text); } else if (event.created_at) { diff --git a/server/lib/device/device.saveStringState.js b/server/lib/device/device.saveStringState.js index 73a5224674..501e38657f 100644 --- a/server/lib/device/device.saveStringState.js +++ b/server/lib/device/device.saveStringState.js @@ -17,6 +17,13 @@ async function saveStringState(device, deviceFeature, newValue) { logger.debug(`device.saveStringState of deviceFeature ${deviceFeature.selector}`); const now = new Date(); + + deviceFeature.last_value_string = newValue; + deviceFeature.last_value_changed = now; + + // save local state in RAM + this.stateManager.setState('deviceFeature', deviceFeature.selector, deviceFeature); + await db.DeviceFeature.update( { last_value_string: newValue, @@ -29,12 +36,6 @@ async function saveStringState(device, deviceFeature, newValue) { }, ); - deviceFeature.last_value_string = newValue; - deviceFeature.last_value_changed = new Date(); - - // save local state in RAM - this.stateManager.setState('deviceFeature', deviceFeature.selector, deviceFeature); - // send websocket event this.eventManager.emit(EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.DEVICE.NEW_STRING_STATE, diff --git a/server/test/lib/device/device.newStateEvent.test.js b/server/test/lib/device/device.newStateEvent.test.js index 945795808e..a326f1a87c 100644 --- a/server/test/lib/device/device.newStateEvent.test.js +++ b/server/test/lib/device/device.newStateEvent.test.js @@ -1,9 +1,14 @@ +const sinon = require('sinon'); + +const { assert } = sinon; + const EventEmitter = require('events'); const { expect } = require('chai'); const Device = require('../../../lib/device'); const StateManager = require('../../../lib/state'); const Job = require('../../../lib/job'); +const { NotFoundError } = require('../../../utils/coreErrors'); const event = new EventEmitter(); const job = new Job(event); @@ -28,8 +33,11 @@ describe('Device.newStateEvent', () => { created_at: '2019-02-12 07:49:07.556 +00:00', updated_at: '2019-02-12 07:49:07.556 +00:00', }); + stateManager.setState('deviceById', '7f85c2f8-86cc-4600-84db-6c074dadb4e8', {}); const device = new Device(event, {}, stateManager, {}, {}, {}, job); await device.newStateEvent({ device_feature_external_id: 'hue:binary:1', state: 12 }); + const newDeviceFeature = stateManager.get('deviceFeature', 'test-device-feature'); + expect(newDeviceFeature).to.have.property('last_value', 12); }); it('should save new string state', async () => { const stateManager = new StateManager(event); @@ -50,6 +58,7 @@ describe('Device.newStateEvent', () => { created_at: '2019-02-12 07:49:07.556 +00:00', updated_at: '2019-02-12 07:49:07.556 +00:00', }); + stateManager.setState('deviceById', '7f85c2f8-86cc-4600-84db-6c074dadb4e8', {}); const device = new Device(event, {}, stateManager, {}, {}, {}, job); await device.newStateEvent({ device_feature_external_id: 'hue:binary:1', text: 'my-text' }); const newDeviceFeature = stateManager.get('deviceFeatureByExternalId', 'hue:binary:1'); @@ -74,6 +83,7 @@ describe('Device.newStateEvent', () => { created_at: '2019-02-12 07:49:07.556 +00:00', updated_at: '2019-02-12 07:49:07.556 +00:00', }; + stateManager.setState('deviceById', '7f85c2f8-86cc-4600-84db-6c074dadb4e8', {}); stateManager.setState('deviceFeatureByExternalId', 'hue:binary:1', currentDeviceFeature); stateManager.setState('deviceFeature', 'test-device-feature', currentDeviceFeature); const device = new Device(event, {}, stateManager, {}, {}, {}, job); @@ -107,6 +117,7 @@ describe('Device.newStateEvent', () => { created_at: '2019-02-12 07:49:07.556 +00:00', updated_at: '2019-02-12 07:49:07.556 +00:00', }; + stateManager.setState('deviceById', '7f85c2f8-86cc-4600-84db-6c074dadb4e8', {}); stateManager.setState('deviceFeatureByExternalId', 'hue:binary:1', currentDeviceFeature); stateManager.setState('deviceFeature', 'test-device-feature', currentDeviceFeature); const device = new Device(event, {}, stateManager, {}, {}, {}, job); @@ -121,4 +132,49 @@ describe('Device.newStateEvent', () => { expect(newDeviceFeature).to.have.property('last_value_changed'); expect(newDeviceFeature.last_value_changed).to.deep.equal(dateInTheFuture); }); + it('should not save state missing device feature', async () => { + const stateManager = new StateManager(event); + const device = new Device(event, {}, stateManager, {}, {}, {}, job); + try { + await device.newStateEvent({ + device_feature_external_id: 'hue:binary:1', + state: 12, + created_at: '2019-02-12 07:49:07.556 +00:00', + }); + assert.fail(); + } catch (e) { + expect(e).to.be.instanceOf(NotFoundError); + } + const newDeviceFeature = stateManager.get('deviceFeatureByExternalId', 'hue:binary:1'); + // eslint-disable-next-line no-unused-expressions + expect(newDeviceFeature).to.be.null; + }); + it('should not save state missing device', async () => { + const stateManager = new StateManager(event); + const currentDeviceFeature = { + id: 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', + name: 'Test device feature', + selector: 'test-device-feature', + external_id: 'hue:binary:1', + device_id: '7f85c2f8-86cc-4600-84db-6c074dadb4e8', + }; + stateManager.setState('deviceFeatureByExternalId', 'hue:binary:1', currentDeviceFeature); + const device = new Device(event, {}, stateManager, {}, {}, {}, job); + try { + await device.newStateEvent({ + device_feature_external_id: 'hue:binary:1', + state: 12, + created_at: '2019-02-12 07:49:07.556 +00:00', + }); + assert.fail(); + } catch (e) { + expect(e).to.be.instanceOf(NotFoundError); + } + const newDeviceFeature = stateManager.get('deviceFeatureByExternalId', 'hue:binary:1'); + expect(newDeviceFeature).not.to.have.property('last_value'); + expect(newDeviceFeature).not.to.have.property('last_value_changed'); + const newDevice = stateManager.get('deviceById', '7f85c2f8-86cc-4600-84db-6c074dadb4e8'); + // eslint-disable-next-line no-unused-expressions + expect(newDevice).to.be.null; + }); });