diff --git a/front/src/assets/integrations/cover/sonoff.jpg b/front/src/assets/integrations/cover/sonoff.jpg deleted file mode 100644 index acea7c7d2f..0000000000 Binary files a/front/src/assets/integrations/cover/sonoff.jpg and /dev/null differ diff --git a/front/src/assets/integrations/cover/tasmota.jpg b/front/src/assets/integrations/cover/tasmota.jpg new file mode 100644 index 0000000000..3e5fbe5c52 Binary files /dev/null and b/front/src/assets/integrations/cover/tasmota.jpg differ diff --git a/front/src/components/app.jsx b/front/src/components/app.jsx index 5857e96ab8..5b95fdbe45 100644 --- a/front/src/components/app.jsx +++ b/front/src/components/app.jsx @@ -73,9 +73,10 @@ import MqttDevicePage from '../routes/integration/all/mqtt/device-page'; import MqttDeviceSetupPage from '../routes/integration/all/mqtt/device-page/setup'; import MqttSetupPage from '../routes/integration/all/mqtt/setup-page'; -// Sonoff -import SonoffPage from '../routes/integration/all/sonoff/device-page'; -import SonoffDiscoverPage from '../routes/integration/all/sonoff/discover-page'; +// Tasmota +import TasmotaPage from '../routes/integration/all/tasmota/device-page'; +import TasmotaEditPage from '../routes/integration/all/tasmota/edit-page'; +import TasmotaDiscoverPage from '../routes/integration/all/tasmota/discover-page'; const defaultState = getDefaultState(); const store = createStore(defaultState); @@ -169,8 +170,9 @@ const AppRouter = connect( - - + + + diff --git a/front/src/components/boxs/device-in-room/device-features/SensorDeviceFeature.jsx b/front/src/components/boxs/device-in-room/device-features/SensorDeviceFeature.jsx index eba8037256..fb533511a7 100644 --- a/front/src/components/boxs/device-in-room/device-features/SensorDeviceFeature.jsx +++ b/front/src/components/boxs/device-in-room/device-features/SensorDeviceFeature.jsx @@ -36,6 +36,8 @@ const SensorDeviceType = ({ children, ...props }) => ( {props.deviceFeature.unit === DEVICE_FEATURE_UNITS.KILOWATT_HOUR && 'kW/h'} {props.deviceFeature.unit === DEVICE_FEATURE_UNITS.LUX && 'Lx'} {props.deviceFeature.unit === DEVICE_FEATURE_UNITS.PASCAL && 'Pa'} + {props.deviceFeature.unit === DEVICE_FEATURE_UNITS.AMPERE && 'A'} + {props.deviceFeature.unit === DEVICE_FEATURE_UNITS.VOLT && 'V'} )} diff --git a/front/src/config/demo.json b/front/src/config/demo.json index 13f9bd67c5..e07bddcfa3 100644 --- a/front/src/config/demo.json +++ b/front/src/config/demo.json @@ -677,11 +677,12 @@ "configured": true, "connected": true }, - "get /api/v1/service/sonoff": {}, - "get /api/v1/service/sonoff/device": [ + "get /api/v1/service/tasmota": {}, + "get /api/v1/service/tasmota/device": [ { "name": "Switch", - "external_id": "sonoff:sonoff-basic", + "external_id": "tasmota:sonoff-basic", + "selector": "sonoff-basic", "room_id": "cecc52c7-3e67-4b75-9b13-9a8867b0443d", "model": "sonoff-basic", "features": [ @@ -692,10 +693,24 @@ ] } ], - "get /api/v1/service/sonoff/discover": [ + "get /api/v1/device/sonoff-basic": { + "name": "Switch", + "external_id": "tasmota:sonoff-basic", + "selector": "sonoff-basic", + "room_id": "cecc52c7-3e67-4b75-9b13-9a8867b0443d", + "model": "sonoff-basic", + "features": [ + { + "category": "switch", + "type": "binary", + "name": "Switch" + } + ] + }, + "get /api/v1/service/tasmota/discover": [ { "name": "Sonoff Basic Kitchen", - "external_id": "sonoff:sonoff-basic", + "external_id": "tasmota:sonoff-basic", "created_at": "2019-02-12T07:49:07.556Z", "model": "sonoff-basic", "features": [ @@ -707,7 +722,7 @@ }, { "name": "Sonoff Pow Kitchen", - "external_id": "sonoff:sonoff-pow", + "external_id": "tasmota:sonoff-pow", "model": "sonoff-pow", "features": [ { @@ -715,6 +730,19 @@ "type": "binary" } ] + }, + { + "name": "Sonoff Mini Outside", + "external_id": "tasmota:sonoff-mini", + "model": "sonoff-basic", + "created_at": "2019-02-12T07:49:07.556Z", + "updatable": true, + "features": [ + { + "category": "switch", + "type": "binary" + } + ] } ], "get /api/v1/service/rtsp-camera/device": [ diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 2094cbfc2b..a1df2d1e51 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -227,41 +227,38 @@ "saveError": "There was an error saving the camera.", "testConnectionError": "There was an error while getting the RTSP Flux. Are you sure the provided URL is right and accessible from Gladys instance?" }, - "sonoff": { - "title": "Sonoff", + "tasmota": { + "title": "Tasmota", "deviceTab": "Devices", "discoverTab": "MQTT discover", - "prepareDeviceDescr": "Or manually prepare future or not discovered devices", "discoverDeviceDescr": "Automatically scan MQTT devices", "device": { - "title": "Sonoff devices in Gladys", + "title": "Tasmota devices in Gladys", "search": "Search devices", - "newButton": "New", - "noDeviceFound": "No Sonoff device found. You can add devices with following actions:" + "editButton": "Edit", + "noDeviceFound": "No Tasmota device found.", + "featuresLabel": "Features" }, "discover": { - "title": "Sonoff discover devices over MQTT", - "description": "Configured Sonoff devices are automatically discovered over MQTT network.", - "error": "Error discovering Sonoff devices.", - "noDeviceFound": "No Sonoff device discovered.", - "documentation": "Sonoff documentation" + "title": "Tasmota discover devices over MQTT", + "description": "Configured Tasmota devices are automatically discovered over MQTT network.", + "error": "Error discovering Tasmota devices.", + "noDeviceFound": "No Tasmota device discovered.", + "documentation": "Tasmota documentation", + "scan": "Scan" }, "nameLabel": "Device Name", "namePlaceholder": "Enter the name of your device", "roomLabel": "Room", - "topicLabel": "Topic (%topic% Sonoff MQTT)", - "topicPlaceholder": "Should starts with 'sonoff:'", - "modelLabel": "Sonoff Model", - "model": { - "sonoff-basic": "Basic", - "sonoff-pow": "Pow", - "sonoff-s2x": "S20/S26 Smart Plug" - }, + "topicLabel": "Tasmota MQTT Topic", "saveButton": "Save", + "updateButton": "Update", "alreadyCreatedButton": "Already created", "deleteButton": "Delete", + "unmanagedModelButton": "Model not managed", "error": { "defaultError": "There was an error saving the device.", + "defaultDeletionError": "There was an error deleting the device.", "conflictError": "Current topic is already in use." } }, @@ -788,7 +785,9 @@ "color": "Light Color", "saturation": "Light Saturation", "temperature": "Light Temperature", - "power": "Light Power consumption" + "power": "Light Power consumption", + "effect-mode": "Effect mode", + "effect-speed": "Effect speed" }, "battery": { "integer": "Battery" @@ -810,6 +809,9 @@ "camera": { "image": "Camera" }, + "counter-sensor": { + "integer": "Counter" + }, "access-control": { "mode": "Access Control Mode" }, diff --git a/front/src/config/integrations/device.en.json b/front/src/config/integrations/device.en.json index c332fecb7c..5cb8fe588a 100644 --- a/front/src/config/integrations/device.en.json +++ b/front/src/config/integrations/device.en.json @@ -30,9 +30,9 @@ "img": "/assets/integrations/cover/philips-hue.jpg" }, { - "key": "sonoff", - "name": "Sonoff", - "description": "Control your Sonoff devices.", - "img": "/assets/integrations/cover/sonoff.jpg" + "key": "tasmota", + "name": "Tasmota", + "description": "Control your Tasmota devices.", + "img": "/assets/integrations/cover/tasmota.jpg" } ] diff --git a/front/src/routes/integration/all/sonoff/device-page/EmptyState.jsx b/front/src/routes/integration/all/sonoff/device-page/EmptyState.jsx deleted file mode 100644 index 810a641767..0000000000 --- a/front/src/routes/integration/all/sonoff/device-page/EmptyState.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Text } from 'preact-i18n'; -import { Link } from 'preact-router/match'; -import cx from 'classnames'; -import style from './style.css'; - -const EmptyState = ({ children, ...props }) => ( -
-
- - -
- - - - -
-
- - -
-
-
-); - -export default EmptyState; diff --git a/front/src/routes/integration/all/sonoff/device-page/SonoffBox.jsx b/front/src/routes/integration/all/sonoff/device-page/SonoffBox.jsx deleted file mode 100644 index dfef67c8f1..0000000000 --- a/front/src/routes/integration/all/sonoff/device-page/SonoffBox.jsx +++ /dev/null @@ -1,221 +0,0 @@ -import { Text, Localizer } from 'preact-i18n'; -import { Component } from 'preact'; -import cx from 'classnames'; -import { GetFeatures, Models } from './models'; -import { DeviceFeatureCategoriesIcon, RequestStatus } from '../../../../../utils/consts'; -import get from 'get-value'; - -class SonoffBox extends Component { - updateName = e => { - this.props.updateDeviceField('sonoffDevices', this.props.deviceIndex, 'name', e.target.value); - - this.setState({ - loading: false - }); - }; - - updateRoom = e => { - this.props.updateDeviceField('sonoffDevices', this.props.deviceIndex, 'room_id', e.target.value); - - this.setState({ - loading: false - }); - }; - - updateTopic = e => { - let { value } = e.target; - if (!value.startsWith('sonoff:')) { - console.log('dont starts with sonoff:', value); - if (value.length < 7) { - console.log('< 7', value); - value = 'sonoff:'; - } else { - console.log('>= 7', value); - value = `sonoff:${value}`; - } - } - - this.props.updateDeviceField('sonoffDevices', this.props.deviceIndex, 'external_id', value); - - this.setState({ - loading: false - }); - }; - - updateModel = e => { - const selectedModel = e.target.value; - console.log(GetFeatures(selectedModel)); - - this.props.updateDeviceField('sonoffDevices', this.props.deviceIndex, 'model', selectedModel); - this.props.updateDeviceField('sonoffDevices', this.props.deviceIndex, 'features', GetFeatures(selectedModel)); - - this.setState({ - loading: false - }); - }; - - saveDevice = async () => { - this.setState({ - loading: true - }); - try { - await this.props.saveDevice('sonoffDevices', this.props.deviceIndex); - this.setState({ - saveError: null - }); - } catch (e) { - this.setState({ - saveError: e - }); - } - this.setState({ - loading: false - }); - }; - - deleteDevice = async () => { - this.setState({ - loading: true - }); - try { - await this.props.deleteDevice(this.props.deviceIndex); - } catch (e) { - this.setState({ - deleteError: RequestStatus.Error - }); - } - this.setState({ - loading: false - }); - }; - - render(props, { loading, saveError }) { - let errorMessage = 'integration.sonoff.error.defaultError'; - if (saveError && saveError.response && saveError.response.status === 409) { - errorMessage = 'integration.sonoff.error.conflictError'; - } - - return ( -
-
-
-
-
-
- {saveError && ( -
- -
- )} -
- - - } - /> - -
- -
- - -
- -
- - - } - /> - -
- -
- - -
- -
- -
- {props.device && - props.device.features && - props.device.features.map(feature => ( - - -
- -
-
- ))} -
-
- -
- - -
-
-
-
-
-
- ); - } -} - -export default SonoffBox; diff --git a/front/src/routes/integration/all/sonoff/device-page/index.js b/front/src/routes/integration/all/sonoff/device-page/index.js deleted file mode 100644 index b14437dd4f..0000000000 --- a/front/src/routes/integration/all/sonoff/device-page/index.js +++ /dev/null @@ -1,24 +0,0 @@ -import { Component } from 'preact'; -import { connect } from 'unistore/preact'; -import actions from '../actions'; -import SonoffPage from '../SonoffPage'; -import DeviceTab from './DeviceTab'; - -@connect('user,sonoffDevices,housesWithRooms,getSonoffStatus', actions) -class SonoffIntegration extends Component { - componentWillMount() { - this.props.getSonoffDevices(100, 0); - this.props.getHouses(); - this.props.getIntegrationByName('sonoff'); - } - - render(props, {}) { - return ( - - - - ); - } -} - -export default SonoffIntegration; diff --git a/front/src/routes/integration/all/sonoff/device-page/models.js b/front/src/routes/integration/all/sonoff/device-page/models.js deleted file mode 100644 index f833e34bdd..0000000000 --- a/front/src/routes/integration/all/sonoff/device-page/models.js +++ /dev/null @@ -1,16 +0,0 @@ -import models from '../../../../../../../server/services/sonoff/models'; - -export const Models = { - 'sonoff-basic': 1, - 'sonoff-pow': 6, - 'sonoff-s2x': 8 -}; - -export const GetFeatures = modelName => { - const modelKey = Models[modelName]; - if (modelKey && models[modelKey]) { - return models[modelKey].getFeatures(); - } - - return []; -}; diff --git a/front/src/routes/integration/all/sonoff/discover-page/DiscoveredBox.jsx b/front/src/routes/integration/all/sonoff/discover-page/DiscoveredBox.jsx deleted file mode 100644 index 2e99c8a73c..0000000000 --- a/front/src/routes/integration/all/sonoff/discover-page/DiscoveredBox.jsx +++ /dev/null @@ -1,177 +0,0 @@ -import { Text, Localizer } from 'preact-i18n'; -import { Component } from 'preact'; -import cx from 'classnames'; -import { DeviceFeatureCategoriesIcon, RequestStatus } from '../../../../../utils/consts'; -import get from 'get-value'; - -class SonoffBox extends Component { - updateName = e => { - this.props.updateDeviceField('discoveredDevices', this.props.deviceIndex, 'name', e.target.value); - }; - - updateRoom = e => { - this.props.updateDeviceField('discoveredDevices', this.props.deviceIndex, 'room_id', e.target.value); - }; - - saveDevice = async () => { - this.setState({ - loading: true - }); - try { - await this.props.saveDevice('discoveredDevices', this.props.deviceIndex); - this.setState({ - saveError: null - }); - } catch (e) { - this.setState({ - saveError: e - }); - } - this.setState({ - loading: false - }); - }; - - deleteDevice = async () => { - this.setState({ - loading: true - }); - try { - await this.props.deleteDevice(this.props.deviceIndex); - } catch (e) { - this.setState({ - deleteError: RequestStatus.Error - }); - } - this.setState({ - loading: false - }); - }; - - render(props, { loading, saveError }) { - let errorMessage = 'integration.sonoff.error.defaultError'; - if (saveError && saveError.response && saveError.response.status === 409) { - errorMessage = 'integration.sonoff.error.conflictError'; - } - - return ( -
-
-
-
-
-
- {saveError && ( -
- -
- )} -
- - - } - /> - -
- -
- - -
- -
- - - - -
- -
- - -
- -
- -
- {props.device && - props.device.features && - props.device.features.map(feature => ( - - -
- -
-
- ))} -
-
- -
- {props.device.created_at && ( - - )} - {!props.device.created_at && ( - - )} -
-
-
-
-
-
- ); - } -} - -export default SonoffBox; diff --git a/front/src/routes/integration/all/sonoff/discover-page/index.js b/front/src/routes/integration/all/sonoff/discover-page/index.js deleted file mode 100644 index 7e20f6baca..0000000000 --- a/front/src/routes/integration/all/sonoff/discover-page/index.js +++ /dev/null @@ -1,24 +0,0 @@ -import { Component } from 'preact'; -import { connect } from 'unistore/preact'; -import actions from '../actions'; -import SonoffPage from '../SonoffPage'; -import DiscoverTab from './DiscoverTab'; - -@connect('user,httpClient,housesWithRooms,discoveredDevices,loading,errorLoading', actions) -class SonoffIntegration extends Component { - async componentWillMount() { - this.props.getDiscoveredSonoffDevices(); - this.props.getHouses(); - this.props.getIntegrationByName('sonoff'); - } - - render(props) { - return ( - - - - ); - } -} - -export default SonoffIntegration; diff --git a/front/src/routes/integration/all/tasmota/TasmotaDeviceBox.jsx b/front/src/routes/integration/all/tasmota/TasmotaDeviceBox.jsx new file mode 100644 index 0000000000..b4cb318893 --- /dev/null +++ b/front/src/routes/integration/all/tasmota/TasmotaDeviceBox.jsx @@ -0,0 +1,209 @@ +import { Component } from 'preact'; +import { Text, Localizer } from 'preact-i18n'; +import cx from 'classnames'; +import { DeviceFeatureCategoriesIcon } from '../../../../utils/consts'; +import get from 'get-value'; +import { Link } from 'preact-router'; + +class TasmotaDeviceBox extends Component { + updateName = e => { + this.props.updateDeviceField(this.props.listName, this.props.deviceIndex, 'name', e.target.value); + + this.setState({ + loading: false + }); + }; + + updateRoom = e => { + this.props.updateDeviceField(this.props.listName, this.props.deviceIndex, 'room_id', e.target.value); + + this.setState({ + loading: false + }); + }; + + saveDevice = async () => { + this.setState({ + loading: true, + errorMessage: null + }); + try { + await this.props.saveDevice(this.props.listName, this.props.deviceIndex); + } catch (e) { + let errorMessage = 'integration.tasmota.error.defaultError'; + if (e.response.status === 409) { + errorMessage = 'integration.tasmota.error.conflictError'; + } + this.setState({ + errorMessage + }); + } + this.setState({ + loading: false + }); + }; + + deleteDevice = async () => { + this.setState({ + loading: true, + errorMessage: null + }); + try { + await this.props.deleteDevice(this.props.deviceIndex); + } catch (e) { + this.setState({ + errorMessage: 'integration.tasmota.error.defaultDeletionError' + }); + } + this.setState({ + loading: false + }); + }; + + render({ deviceIndex, device, housesWithRooms, editable, ...props }, { loading, errorMessage }) { + const validModel = device.features.length > 0; + + return ( +
+
+
{device.name}
+
+
+
+
+ {errorMessage && ( +
+ +
+ )} +
+ + + } + disabled={!editable || !validModel} + /> + +
+ +
+ + +
+ +
+ + + + +
+ + {device.features && device.features.length > 0 && ( +
+ +
+ {device.features.map(feature => ( + + +
+ +
+
+ ))} +
+
+ )} + +
+ {validModel && props.alreadyCreatedButton && ( + + )} + + {validModel && props.updateButton && ( + + )} + + {validModel && props.saveButton && ( + + )} + + {validModel && props.deleteButton && ( + + )} + + {!validModel && ( + + )} + + {validModel && props.editButton && ( + + + + )} +
+
+
+
+
+
+ ); + } +} + +export default TasmotaDeviceBox; diff --git a/front/src/routes/integration/all/sonoff/SonoffPage.js b/front/src/routes/integration/all/tasmota/TasmotaPage.js similarity index 76% rename from front/src/routes/integration/all/sonoff/SonoffPage.js rename to front/src/routes/integration/all/tasmota/TasmotaPage.js index f13b897eb1..a09c2b4de4 100644 --- a/front/src/routes/integration/all/sonoff/SonoffPage.js +++ b/front/src/routes/integration/all/tasmota/TasmotaPage.js @@ -2,7 +2,7 @@ import { Text } from 'preact-i18n'; import { Link } from 'preact-router/match'; import DeviceConfigurationLink from '../../../../components/documentation/DeviceConfigurationLink'; -const SonoffPage = ({ children, user }) => ( +const TasmotaPage = ({ children, user }) => (
@@ -10,41 +10,41 @@ const SonoffPage = ({ children, user }) => (

- +

- + - + - +
@@ -58,4 +58,4 @@ const SonoffPage = ({ children, user }) => (
); -export default SonoffPage; +export default TasmotaPage; diff --git a/front/src/routes/integration/all/sonoff/actions.js b/front/src/routes/integration/all/tasmota/actions.js similarity index 61% rename from front/src/routes/integration/all/sonoff/actions.js rename to front/src/routes/integration/all/tasmota/actions.js index 8a3af306b0..c755f13b13 100644 --- a/front/src/routes/integration/all/sonoff/actions.js +++ b/front/src/routes/integration/all/tasmota/actions.js @@ -1,53 +1,41 @@ import update from 'immutability-helper'; import debounce from 'debounce'; -import uuid from 'uuid'; import { RequestStatus } from '../../../../utils/consts'; import createActionsIntegration from '../../../../actions/integration'; -import { DEVICE_FEATURE_TYPES } from '../../../../../../server/utils/constants'; function createActions(store) { const integrationActions = createActionsIntegration(store); const actions = { - async getSonoffDevices(state, take, skip) { + async getTasmotaDevices(state) { store.setState({ - getSonoffStatus: RequestStatus.Getting + getTasmotaStatus: RequestStatus.Getting }); try { const options = { - order_dir: state.getSonoffOrderDir || 'asc', - take, - skip + order_dir: state.getTasmotaOrderDir || 'asc' }; - if (state.sonoffSearch && state.sonoffSearch.length) { - options.search = state.sonoffSearch; + if (state.tasmotaSearch && state.tasmotaSearch.length) { + options.search = state.tasmotaSearch; } - const sonoffsReceived = await state.httpClient.get('/api/v1/service/sonoff/device', options); - let sonoffDevices; - if (skip === 0) { - sonoffDevices = sonoffsReceived; - } else { - sonoffDevices = update(state.sonoffDevices, { - $push: sonoffsReceived - }); - } + const tasmotaDevices = await state.httpClient.get('/api/v1/service/tasmota/device', options); store.setState({ - sonoffDevices, - getSonoffStatus: RequestStatus.Success + tasmotaDevices, + getTasmotaStatus: RequestStatus.Success }); } catch (e) { store.setState({ philipsHueGetBridgesStatus: RequestStatus.Error, - getSonoffStatus: e.message + getTasmotaStatus: e.message }); } }, - async getDiscoveredSonoffDevices(state) { + async getDiscoveredTasmotaDevices(state) { store.setState({ loading: true }); try { - const discoveredDevices = await state.httpClient.get('/api/v1/service/sonoff/discover'); + const discoveredDevices = await state.httpClient.get('/api/v1/service/tasmota/discover'); store.setState({ discoveredDevices, loading: false, @@ -79,23 +67,6 @@ function createActions(store) { }); } }, - addDevice(state) { - const uniqueId = uuid.v4(); - const sonoffDevices = update(state.sonoffDevices, { - $push: [ - { - id: uniqueId, - name: null, - should_poll: false, - service_id: state.currentIntegration.id, - external_id: 'sonoff:' - } - ] - }); - store.setState({ - sonoffDevices - }); - }, updateDeviceField(state, listName, index, field, value) { const devices = update(state[listName], { [index]: { @@ -127,17 +98,6 @@ function createActions(store) { }, async saveDevice(state, listName, index) { const device = state[listName][index]; - device.selector = device.external_id; - - device.features.forEach(feature => { - feature.name = device.name; - if (DEVICE_FEATURE_TYPES.SWITCH.BINARY !== feature.type) { - feature.name += ` - ${feature.type}`; - } - feature.external_id = `${device.external_id}:${feature.category}:${feature.type}`; - feature.selector = feature.external_id; - }); - const savedDevice = await state.httpClient.post(`/api/v1/device`, device); const devices = update(state[listName], { $splice: [[index, 1, savedDevice]] @@ -147,28 +107,72 @@ function createActions(store) { }); }, async deleteDevice(state, index) { - const device = state.sonoffDevices[index]; + const device = state.tasmotaDevices[index]; if (device.created_at) { await state.httpClient.delete(`/api/v1/device/${device.selector}`); } - const sonoffDevices = update(state.sonoffDevices, { + const tasmotaDevices = update(state.tasmotaDevices, { $splice: [[index, 1]] }); store.setState({ - sonoffDevices + tasmotaDevices }); }, async search(state, e) { store.setState({ - sonoffSearch: e.target.value + tasmotaSearch: e.target.value }); - await actions.getSonoffDevices(store.getState(), 20, 0); + await actions.getTasmotaDevices(store.getState()); }, async changeOrderDir(state, e) { store.setState({ - getSonoffOrderDir: e.target.value + getTasmotaOrderDir: e.target.value + }); + await actions.getTasmotaDevices(store.getState()); + }, + async forceScan(state) { + store.setState({ + loading: true + }); + try { + await state.httpClient.post('/api/v1/service/tasmota/discover'); + store.setState({ + discoveredDevices: [], + errorLoading: false + }); + + setTimeout(store.setState, 5000, { + loading: false + }); + } catch (e) { + store.setState({ + loading: false, + errorLoading: true + }); + } + }, + addDiscoveredDevice(state, newDevice) { + const existingDevices = state.discoveredDevices || []; + const newDevices = []; + + let added = false; + existingDevices.forEach(device => { + if (device.external_id === newDevice.external_id) { + newDevices.push(newDevice); + added = true; + } else { + newDevices.push(device); + } + }); + + if (!added) { + newDevices.push(newDevice); + } + + store.setState({ + discoveredDevices: newDevices, + loading: false }); - await actions.getSonoffDevices(store.getState(), 20, 0); } }; actions.debouncedSearch = debounce(actions.search, 200); diff --git a/front/src/routes/integration/all/sonoff/device-page/DeviceTab.jsx b/front/src/routes/integration/all/tasmota/device-page/DeviceTab.jsx similarity index 61% rename from front/src/routes/integration/all/sonoff/device-page/DeviceTab.jsx rename to front/src/routes/integration/all/tasmota/device-page/DeviceTab.jsx index b9b7fa1cde..53cdd475ef 100644 --- a/front/src/routes/integration/all/sonoff/device-page/DeviceTab.jsx +++ b/front/src/routes/integration/all/tasmota/device-page/DeviceTab.jsx @@ -2,16 +2,16 @@ import { Text, Localizer } from 'preact-i18n'; import cx from 'classnames'; import EmptyState from './EmptyState'; -import SonoffBox from './SonoffBox'; import { RequestStatus } from '../../../../../utils/consts'; import style from './style.css'; import CheckMqttPanel from '../../mqtt/commons/CheckMqttPanel'; +import TasmotaDeviceBox from '../TasmotaDeviceBox'; const DeviceTab = ({ children, ...props }) => (

- +

} + placeholder={} onInput={props.debouncedSearch} />
-
@@ -45,16 +42,27 @@ const DeviceTab = ({ children, ...props }) => (
-
+
- {props.sonoffDevices && - props.sonoffDevices.length > 0 && - props.sonoffDevices.map((device, index) => )} - {!props.sonoffDevices || (props.sonoffDevices.length === 0 && )} + {props.tasmotaDevices && + props.tasmotaDevices.length > 0 && + props.tasmotaDevices.map((device, index) => ( + + ))} + {!props.tasmotaDevices || (props.tasmotaDevices.length === 0 && )}
diff --git a/front/src/routes/integration/all/tasmota/device-page/EmptyState.jsx b/front/src/routes/integration/all/tasmota/device-page/EmptyState.jsx new file mode 100644 index 0000000000..1ba3ee02cc --- /dev/null +++ b/front/src/routes/integration/all/tasmota/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/tasmota/device-page/index.js b/front/src/routes/integration/all/tasmota/device-page/index.js new file mode 100644 index 0000000000..ffc00ffeda --- /dev/null +++ b/front/src/routes/integration/all/tasmota/device-page/index.js @@ -0,0 +1,24 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import actions from '../actions'; +import TasmotaPage from '../TasmotaPage'; +import DeviceTab from './DeviceTab'; + +@connect('user,tasmotaDevices,housesWithRooms,getTasmotaStatus', actions) +class TasmotaIntegration extends Component { + componentWillMount() { + this.props.getTasmotaDevices(); + this.props.getHouses(); + this.props.getIntegrationByName('tasmota'); + } + + render(props, {}) { + return ( + + + + ); + } +} + +export default TasmotaIntegration; diff --git a/front/src/routes/integration/all/sonoff/device-page/style.css b/front/src/routes/integration/all/tasmota/device-page/style.css similarity index 77% rename from front/src/routes/integration/all/sonoff/device-page/style.css rename to front/src/routes/integration/all/tasmota/device-page/style.css index 541ceeaa56..c75588b677 100644 --- a/front/src/routes/integration/all/sonoff/device-page/style.css +++ b/front/src/routes/integration/all/tasmota/device-page/style.css @@ -2,6 +2,6 @@ margin-top: 35px; } -.sonoffListBody { +.tasmotaListBody { min-height: 200px } diff --git a/front/src/routes/integration/all/sonoff/discover-page/DiscoverTab.jsx b/front/src/routes/integration/all/tasmota/discover-page/DiscoverTab.jsx similarity index 50% rename from front/src/routes/integration/all/sonoff/discover-page/DiscoverTab.jsx rename to front/src/routes/integration/all/tasmota/discover-page/DiscoverTab.jsx index 9abea64199..a99399cf63 100644 --- a/front/src/routes/integration/all/sonoff/discover-page/DiscoverTab.jsx +++ b/front/src/routes/integration/all/tasmota/discover-page/DiscoverTab.jsx @@ -2,22 +2,27 @@ import { Text } from 'preact-i18n'; import cx from 'classnames'; import EmptyState from './EmptyState'; -import DiscoveredBox from './DiscoveredBox'; import style from './style.css'; import CheckMqttPanel from '../../mqtt/commons/CheckMqttPanel'; +import TasmotaDeviceBox from '../TasmotaDeviceBox'; const DeviceTab = ({ children, ...props }) => (

- +

+
+ +
- +
( })} >
-
+
{props.errorLoading && (

- +

)}
{props.discoveredDevices && props.discoveredDevices.map((device, index) => ( - + ))} {!props.discoveredDevices || (props.discoveredDevices.length === 0 && )}
diff --git a/front/src/routes/integration/all/sonoff/discover-page/EmptyState.jsx b/front/src/routes/integration/all/tasmota/discover-page/EmptyState.jsx similarity index 80% rename from front/src/routes/integration/all/sonoff/discover-page/EmptyState.jsx rename to front/src/routes/integration/all/tasmota/discover-page/EmptyState.jsx index c51666c298..4fafc5f8a5 100644 --- a/front/src/routes/integration/all/sonoff/discover-page/EmptyState.jsx +++ b/front/src/routes/integration/all/tasmota/discover-page/EmptyState.jsx @@ -5,7 +5,7 @@ import style from './style.css'; const EmptyState = ({ children, ...props }) => (
- +
); diff --git a/front/src/routes/integration/all/tasmota/discover-page/index.js b/front/src/routes/integration/all/tasmota/discover-page/index.js new file mode 100644 index 0000000000..7f05a9b770 --- /dev/null +++ b/front/src/routes/integration/all/tasmota/discover-page/index.js @@ -0,0 +1,30 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import actions from '../actions'; +import TasmotaPage from '../TasmotaPage'; +import DiscoverTab from './DiscoverTab'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../../../server/utils/constants'; + +@connect('user,session,httpClient,housesWithRooms,discoveredDevices,loading,errorLoading', actions) +class TasmotaIntegration extends Component { + async componentWillMount() { + this.props.getDiscoveredTasmotaDevices(); + this.props.getHouses(); + this.props.getIntegrationByName('tasmota'); + + this.props.session.dispatcher.addListener( + WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + this.props.addDiscoveredDevice + ); + } + + render(props) { + return ( + + + + ); + } +} + +export default TasmotaIntegration; diff --git a/front/src/routes/integration/all/sonoff/discover-page/style.css b/front/src/routes/integration/all/tasmota/discover-page/style.css similarity index 52% rename from front/src/routes/integration/all/sonoff/discover-page/style.css rename to front/src/routes/integration/all/tasmota/discover-page/style.css index a9ccdf968d..83cbcd563d 100644 --- a/front/src/routes/integration/all/sonoff/discover-page/style.css +++ b/front/src/routes/integration/all/tasmota/discover-page/style.css @@ -2,6 +2,6 @@ margin-top: 89px; } -.sonoffListBody { - min-height: 200px +.tasmotaListBody { + min-height: 200px; } diff --git a/front/src/routes/integration/all/tasmota/edit-page/index.js b/front/src/routes/integration/all/tasmota/edit-page/index.js new file mode 100644 index 0000000000..d536a33965 --- /dev/null +++ b/front/src/routes/integration/all/tasmota/edit-page/index.js @@ -0,0 +1,22 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import TasmotaPage from '../TasmotaPage'; +import UpdateDevice from '../../../../../components/device'; + +@connect('user,session,httpClient,currentIntegration,houses', {}) +class EditTasmotaDevice extends Component { + render(props, {}) { + return ( + + + + ); + } +} + +export default EditTasmotaDevice; diff --git a/front/src/utils/consts.js b/front/src/utils/consts.js index 9c58833246..2422f46ab5 100644 --- a/front/src/utils/consts.js +++ b/front/src/utils/consts.js @@ -93,7 +93,9 @@ export const DeviceFeatureCategoriesIcon = { [DEVICE_FEATURE_TYPES.LIGHT.COLOR]: 'sun', [DEVICE_FEATURE_TYPES.LIGHT.SATURATION]: 'sun', [DEVICE_FEATURE_TYPES.LIGHT.TEMPERATURE]: 'sun', - [DEVICE_FEATURE_TYPES.LIGHT.POWER]: 'zap' + [DEVICE_FEATURE_TYPES.LIGHT.POWER]: 'zap', + [DEVICE_FEATURE_TYPES.LIGHT.EFFECT_MODE]: 'play', + [DEVICE_FEATURE_TYPES.LIGHT.EFFECT_SPEED]: 'activity' }, [DEVICE_FEATURE_CATEGORIES.TEMPERATURE_SENSOR]: { [DEVICE_FEATURE_TYPES.SENSOR.DECIMAL]: 'thermometer' @@ -106,6 +108,9 @@ export const DeviceFeatureCategoriesIcon = { [DEVICE_FEATURE_TYPES.VIBRATION_SENSOR.ACCELERATION_Z]: 'zap', [DEVICE_FEATURE_TYPES.VIBRATION_SENSOR.BED_ACTIVITY]: 'moon' }, + [DEVICE_FEATURE_CATEGORIES.COUNTER_SENSOR]: { + [DEVICE_FEATURE_TYPES.SENSOR.INTEGER]: 'plus' + }, [DEVICE_FEATURE_CATEGORIES.LIGHT_SENSOR]: { [DEVICE_FEATURE_TYPES.SENSOR.INTEGER]: 'sun', [DEVICE_FEATURE_TYPES.SENSOR.DECIMAL]: 'sun' diff --git a/server/migrations/20200104194451-sonoff-to-tasmota.js b/server/migrations/20200104194451-sonoff-to-tasmota.js new file mode 100644 index 0000000000..f08b7486f9 --- /dev/null +++ b/server/migrations/20200104194451-sonoff-to-tasmota.js @@ -0,0 +1,87 @@ +const Promise = require('bluebird'); +const db = require('../models'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + await db.sequelize.transaction(async (transaction) => { + const service = await db.Service.findOne({ + where: { + selector: 'sonoff', + }, + }); + + if (service) { + // Update service name and selector + await service.update({ name: 'tasmota', selector: 'tasmota' }, { transaction }); + + const devices = await db.Device.findAll({ + where: { + service_id: service.id, + }, + }); + + // Update devices external_id and selector + await Promise.map(devices, async (device) => { + const realDevice = device.get({ plain: true }); + + // Update features external_id and selector + const dbFeatures = await db.DeviceFeature.findAll({ + where: { + device_id: realDevice.id, + }, + }); + await Promise.map(dbFeatures, async (dbFeature) => { + const newExternalId = dbFeature.external_id.replace(/^sonoff/, 'tasmota'); + const selector = dbFeature.selector.replace(/^sonoff/, 'tasmota'); + await dbFeature.update({ external_id: newExternalId, selector }, { transaction }); + }); + + const newExternalId = realDevice.external_id.replace(/^sonoff/, 'tasmota'); + const selector = realDevice.selector.replace(/^sonoff/, 'tasmota'); + await device.update({ external_id: newExternalId, selector }, { transaction }); + }); + } + }); + }, + down: async (queryInterface, Sequelize) => { + await db.sequelize.transaction(async (transaction) => { + const service = await db.Service.findOne({ + where: { + name: 'tasmota', + }, + }); + + if (service) { + // Update service name and selector + await service.update({ name: 'sonoff', selector: 'sonoff' }, { transaction }); + + const devices = await db.Device.findAll({ + where: { + service_id: service.id, + }, + }); + + // Update devices external_id and selector + await Promise.map(devices, async (device) => { + const realDevice = device.get({ plain: true }); + + // Update features external_id and selector + const dbFeatures = await db.DeviceFeature.findAll({ + where: { + device_id: realDevice.id, + }, + }); + await Promise.map(dbFeatures, async (dbFeature) => { + const newExternalId = dbFeature.external_id.replace(/^tasmota/, 'sonoff'); + const selector = dbFeature.selector.replace(/^tasmota/, 'sonoff'); + await dbFeature.update({ external_id: newExternalId, selector }, { transaction }); + }); + + const newExternalId = realDevice.external_id.replace(/^tasmota/, 'sonoff'); + const selector = realDevice.selector.replace(/^tasmota/, 'sonoff'); + await device.update({ external_id: newExternalId, selector }, { transaction }); + }); + } + }); + }, +}; diff --git a/server/services/index.js b/server/services/index.js index de065fb04b..c67c6eb9e1 100644 --- a/server/services/index.js +++ b/server/services/index.js @@ -7,4 +7,4 @@ module.exports.telegram = require('./telegram'); module.exports.usb = require('./usb'); module.exports.xiaomi = require('./xiaomi'); module.exports.zwave = require('./zwave'); -module.exports.sonoff = require('./sonoff'); +module.exports.tasmota = require('./tasmota'); diff --git a/server/services/mqtt/lib/publishMessage.js b/server/services/mqtt/lib/publishMessage.js index 051af6c17e..937eb53492 100644 --- a/server/services/mqtt/lib/publishMessage.js +++ b/server/services/mqtt/lib/publishMessage.js @@ -5,7 +5,7 @@ const logger = require('../../../utils/logger'); * @param {string} topic - Topic to publish to. * @param {any} message - Message to send. * @example - * publishMessage('cmnd/sonoff/POWER', 'ON'); + * publishMessage('cmnd/tasmota/POWER', 'ON'); */ function publishMessage(topic, message = undefined) { this.mqttClient.publish(topic, message, undefined, (err) => { diff --git a/server/services/sonoff/api/sonoff.controller.js b/server/services/sonoff/api/sonoff.controller.js deleted file mode 100644 index c2a2ec5de5..0000000000 --- a/server/services/sonoff/api/sonoff.controller.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = function MqttController(mqttManager) { - /** - * @api {get} /api/v1/service/sonoff/discover Get discovered Sonoff devices - * @apiName discover - * @apiGroup Sonoff - */ - function discover(req, res) { - res.json(mqttManager.getDiscoveredDevices()); - } - - return { - 'get /api/v1/service/sonoff/discover': { - authenticated: true, - controller: discover, - }, - }; -}; diff --git a/server/services/sonoff/index.js b/server/services/sonoff/index.js deleted file mode 100644 index 8e2e0a8102..0000000000 --- a/server/services/sonoff/index.js +++ /dev/null @@ -1,36 +0,0 @@ -const logger = require('../../utils/logger'); -const SonoffHandler = require('./lib'); -const SonoffController = require('./api/sonoff.controller'); - -module.exports = function SonoffService(gladys, serviceId) { - const sonoffHandler = new SonoffHandler(gladys, serviceId); - - /** - * @public - * @description This function starts service - * @example - * gladys.services.sonoff.start(); - */ - function start() { - logger.log('starting Sonoff service'); - sonoffHandler.connect(); - } - - /** - * @public - * @description This function stops the service - * @example - * gladys.services.sonoff.stop(); - */ - function stop() { - logger.log('stopping Sonoff service'); - sonoffHandler.disconnect(); - } - - return Object.freeze({ - start, - stop, - device: sonoffHandler, - controllers: SonoffController(sonoffHandler), - }); -}; diff --git a/server/services/sonoff/lib/handleMqttMessage.js b/server/services/sonoff/lib/handleMqttMessage.js deleted file mode 100644 index 6cf5297c9b..0000000000 --- a/server/services/sonoff/lib/handleMqttMessage.js +++ /dev/null @@ -1,94 +0,0 @@ -const logger = require('../../../utils/logger'); -const { EVENTS, DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../utils/constants'); -const models = require('../models'); - -/** - * @description Handle a new message receive in MQTT. - * @param {string} topic - MQTT topic. - * @param {Object} message - The message sent. - * @example - * handleMqttMessage('stat/sonoff/POWER', 'ON'); - */ -function handleMqttMessage(topic, message) { - const splittedTopic = topic.split('/'); - const eventType = splittedTopic[2]; - const deviceExternalId = splittedTopic[1]; - let event; - - switch (eventType) { - // Power status - case 'POWER': { - event = { - device_feature_external_id: `sonoff:${deviceExternalId}:${DEVICE_FEATURE_CATEGORIES.SWITCH}:${DEVICE_FEATURE_TYPES.SWITCH.BINARY}`, - state: message === 'ON' ? 1 : 0, - }; - break; - } - // Sensor status - case 'SENSOR': { - const sensorMsg = JSON.parse(message); - - event = { - device_feature_external_id: `sonoff:${deviceExternalId}:${DEVICE_FEATURE_CATEGORIES.SWITCH}:${DEVICE_FEATURE_TYPES.SWITCH.POWER}`, - state: sensorMsg.ENERGY.Current, - }; - break; - } - // Device global status - case 'STATUS': { - const statusMsg = JSON.parse(message); - const statusValue = statusMsg.Status.Power; - const friendlyName = statusMsg.Status.FriendlyName[0]; - const moduleId = statusMsg.Status.Module; - - const model = models[moduleId]; - if (model) { - this.mqttDevices[deviceExternalId] = { - name: friendlyName, - external_id: `sonoff:${deviceExternalId}`, - features: model.getFeatures(), - model: model.getModel(), - service_id: this.serviceId, - should_poll: false, - }; - - event = { - device_feature_external_id: `sonoff:${deviceExternalId}:${DEVICE_FEATURE_CATEGORIES.SWITCH}:${DEVICE_FEATURE_TYPES.SWITCH.BINARY}`, - state: statusValue, - }; - } else { - logger.warn(`MQTT : Sonoff model ${moduleId} (${friendlyName}) not managed`); - } - - break; - } - // Device state topic - case 'RESULT': - case 'STATE': { - const stateMsg = JSON.parse(message); - const stateValue = stateMsg.POWER; - - event = { - device_feature_external_id: `sonoff:${deviceExternalId}:${DEVICE_FEATURE_CATEGORIES.SWITCH}:${DEVICE_FEATURE_TYPES.SWITCH.BINARY}`, - state: stateValue === 'ON' ? 1 : 0, - }; - break; - } - // Online status - case 'LWT': { - this.mqttService.device.publish(`cmnd/${deviceExternalId}/status`); - break; - } - default: { - logger.info(`MQTT : Sonoff topic ${topic} not handled.`); - } - } - - if (event) { - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, event); - } -} - -module.exports = { - handleMqttMessage, -}; diff --git a/server/services/sonoff/lib/index.js b/server/services/sonoff/lib/index.js deleted file mode 100644 index c5c46d8077..0000000000 --- a/server/services/sonoff/lib/index.js +++ /dev/null @@ -1,27 +0,0 @@ -const { connect } = require('./connect'); -const { disconnect } = require('./disconnect'); -const { handleMqttMessage } = require('./handleMqttMessage'); -const { getDiscoveredDevices } = require('./getDiscoveredDevices'); -const { setValue } = require('./setValue'); - -/** - * @description Add ability to connect to Sonoff devices. - * @param {Object} gladys - Gladys instance. - * @param {string} serviceId - UUID of the service in DB. - * @example - * const sonoffHandler = new SonoffHandler(gladys, serviceId); - */ -const SonoffHandler = function SonoffHandler(gladys, serviceId) { - this.gladys = gladys; - this.serviceId = serviceId; - this.mqttService = null; - this.mqttDevices = {}; -}; - -SonoffHandler.prototype.connect = connect; -SonoffHandler.prototype.disconnect = disconnect; -SonoffHandler.prototype.handleMqttMessage = handleMqttMessage; -SonoffHandler.prototype.getDiscoveredDevices = getDiscoveredDevices; -SonoffHandler.prototype.setValue = setValue; - -module.exports = SonoffHandler; diff --git a/server/services/sonoff/lib/setValue.js b/server/services/sonoff/lib/setValue.js deleted file mode 100644 index cb596ead2b..0000000000 --- a/server/services/sonoff/lib/setValue.js +++ /dev/null @@ -1,29 +0,0 @@ -const { BadParameters } = require('../../../utils/coreErrors'); - -/** - * @description Send the new device value over MQTT. - * @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); - */ -function setValue(device, deviceFeature, value) { - // Remove first 'sonoff:' substring - const externalId = device.external_id; - - if (!externalId.startsWith('sonoff:')) { - throw new BadParameters(`Sonoff device external_id is invalid : "${externalId}" should starts with "sonoff:"`); - } - const topic = externalId.substring(7); - if (topic.length === 0) { - throw new BadParameters(`Sonoff device external_id is invalid : "${externalId}" have no MQTT topic`); - } - - // Send message to Sonoff topics - this.mqttService.device.publish(`cmnd/${topic}/power`, value ? 'ON' : 'OFF'); -} - -module.exports = { - setValue, -}; diff --git a/server/services/sonoff/models/index.js b/server/services/sonoff/models/index.js deleted file mode 100644 index bfbd9f744b..0000000000 --- a/server/services/sonoff/models/index.js +++ /dev/null @@ -1,9 +0,0 @@ -const basic = require('./switch_basic'); -const s2x = require('./plug_s2x'); -const pow = require('./switch_pow'); - -module.exports = { - 1: basic, - 6: pow, - 8: s2x, -}; diff --git a/server/services/sonoff/models/plug_s2x.js b/server/services/sonoff/models/plug_s2x.js deleted file mode 100644 index 4bc63a34be..0000000000 --- a/server/services/sonoff/models/plug_s2x.js +++ /dev/null @@ -1,23 +0,0 @@ -const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../utils/constants'); - -const getModel = () => { - return 'sonoff-s2x'; -}; - -const getFeatures = () => { - return [ - { - category: DEVICE_FEATURE_CATEGORIES.SWITCH, - type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, - read_only: false, - has_feedback: true, - min: 0, - max: 1, - }, - ]; -}; - -module.exports = { - getFeatures, - getModel, -}; diff --git a/server/services/sonoff/models/switch_basic.js b/server/services/sonoff/models/switch_basic.js deleted file mode 100644 index 85f675bdc1..0000000000 --- a/server/services/sonoff/models/switch_basic.js +++ /dev/null @@ -1,23 +0,0 @@ -const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../utils/constants'); - -const getModel = () => { - return 'sonoff-basic'; -}; - -const getFeatures = () => { - return [ - { - category: DEVICE_FEATURE_CATEGORIES.SWITCH, - type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, - read_only: false, - has_feedback: true, - min: 0, - max: 1, - }, - ]; -}; - -module.exports = { - getFeatures, - getModel, -}; diff --git a/server/services/sonoff/models/switch_pow.js b/server/services/sonoff/models/switch_pow.js deleted file mode 100644 index d431ced1f5..0000000000 --- a/server/services/sonoff/models/switch_pow.js +++ /dev/null @@ -1,32 +0,0 @@ -const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../utils/constants'); - -const getModel = () => { - return 'sonoff-pow'; -}; - -const getFeatures = () => { - return [ - { - category: DEVICE_FEATURE_CATEGORIES.SWITCH, - type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, - read_only: false, - has_feedback: true, - min: 0, - max: 1, - }, - { - category: DEVICE_FEATURE_CATEGORIES.SWITCH, - type: DEVICE_FEATURE_TYPES.SWITCH.POWER, - read_only: true, - has_feedback: false, - min: 0, - max: 10000, - unit: 'A', - }, - ]; -}; - -module.exports = { - getFeatures, - getModel, -}; diff --git a/server/services/tasmota/api/tasmota.controller.js b/server/services/tasmota/api/tasmota.controller.js new file mode 100644 index 0000000000..e41fd9fe36 --- /dev/null +++ b/server/services/tasmota/api/tasmota.controller.js @@ -0,0 +1,33 @@ +module.exports = function MqttController(mqttManager) { + /** + * @api {get} /api/v1/service/tasmota/discover Get discovered Tasmota devices + * @apiName discover + * @apiGroup Tasmota + */ + function discover(req, res) { + res.json(mqttManager.getDiscoveredDevices()); + } + + /** + * @api {post} /api/v1/service/tasmota/discover Force to discover Tasmota devices + * @apiName scan + * @apiGroup Tasmota + */ + function scan(req, res) { + mqttManager.forceScan(); + res.json({ + success: true, + }); + } + + return { + 'get /api/v1/service/tasmota/discover': { + authenticated: true, + controller: discover, + }, + 'post /api/v1/service/tasmota/discover': { + authenticated: true, + controller: scan, + }, + }; +}; diff --git a/server/services/tasmota/index.js b/server/services/tasmota/index.js new file mode 100644 index 0000000000..7eaa35c07a --- /dev/null +++ b/server/services/tasmota/index.js @@ -0,0 +1,36 @@ +const logger = require('../../utils/logger'); +const TasmotaHandler = require('./lib'); +const TasmotaController = require('./api/tasmota.controller'); + +module.exports = function TasmotaService(gladys, serviceId) { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + /** + * @public + * @description This function starts service + * @example + * gladys.services.tasmota.start(); + */ + function start() { + logger.log('starting Tasmota service'); + tasmotaHandler.connect(); + } + + /** + * @public + * @description This function stops the service + * @example + * gladys.services.tasmota.stop(); + */ + function stop() { + logger.log('stopping Tasmota service'); + tasmotaHandler.disconnect(); + } + + return Object.freeze({ + start, + stop, + device: tasmotaHandler, + controllers: TasmotaController(tasmotaHandler), + }); +}; diff --git a/server/services/sonoff/lib/connect.js b/server/services/tasmota/lib/connect.js similarity index 92% rename from server/services/sonoff/lib/connect.js rename to server/services/tasmota/lib/connect.js index 8e37eb6d55..82d9a46569 100644 --- a/server/services/sonoff/lib/connect.js +++ b/server/services/tasmota/lib/connect.js @@ -7,7 +7,7 @@ function connect() { // Loads MQTT service this.mqttService = this.gladys.service.getService('mqtt'); - // Subscribe to Sonoff topics + // Subscribe to Tasmota topics this.mqttService.device.subscribe('stat/+/+', this.handleMqttMessage.bind(this)); this.mqttService.device.subscribe('tele/+/+', this.handleMqttMessage.bind(this)); } diff --git a/server/services/sonoff/lib/disconnect.js b/server/services/tasmota/lib/disconnect.js similarity index 87% rename from server/services/sonoff/lib/disconnect.js rename to server/services/tasmota/lib/disconnect.js index 4368550b4d..c48f617bd7 100644 --- a/server/services/sonoff/lib/disconnect.js +++ b/server/services/tasmota/lib/disconnect.js @@ -4,7 +4,7 @@ * disconnect(); */ function disconnect() { - // Unsubscribe to Sonoff topics + // Unsubscribe to Tasmota topics this.mqttService.device.unsubscribe('stat/+/+'); this.mqttService.device.unsubscribe('tele/+/+'); } diff --git a/server/services/tasmota/lib/features/colorChannel.js b/server/services/tasmota/lib/features/colorChannel.js new file mode 100644 index 0000000000..b3f72305bc --- /dev/null +++ b/server/services/tasmota/lib/features/colorChannel.js @@ -0,0 +1,36 @@ +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); + +module.exports = { + // Tasmota matcher + keyMatcher: /^(StatusSTS|Gladys)\.Channel$/, + // Gladys feature + generateFeature: (device, key, value) => { + const channelSize = value.length; + if (channelSize >= 3) { + return { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.COLOR, + name: 'Color', + read_only: false, + has_feedback: true, + min: 0, + max: 16777215, + }; + } + + return null; + }, + // Gladys vs Tasmota transformers + readValue: (value) => { + return value.slice(0, 3).reduce((acc, cur, i) => { + return acc + ((cur * 255) / 100) * 256 ** (2 - i); + }, 0); + }, + writeValue: (value) => { + const blue = value % 256; + const green = ((value - blue) / 256) % 256; + const red = ((value - green * 256 - blue) / 65536) % 256; + + return `${red},${green},${blue}`; + }, +}; diff --git a/server/services/tasmota/lib/features/colorScheme.js b/server/services/tasmota/lib/features/colorScheme.js new file mode 100644 index 0000000000..6b06be7155 --- /dev/null +++ b/server/services/tasmota/lib/features/colorScheme.js @@ -0,0 +1,18 @@ +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); + +module.exports = { + // Tasmota matcher + keyMatcher: /^(StatusSTS|Gladys)\.Scheme$/, + // Gladys feature + generateFeature: () => { + return { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.EFFECT_MODE, + name: 'Effect mode', + read_only: false, + has_feedback: true, + min: 0, + max: 4, + }; + }, +}; diff --git a/server/services/tasmota/lib/features/colorSpeed.js b/server/services/tasmota/lib/features/colorSpeed.js new file mode 100644 index 0000000000..8e39047040 --- /dev/null +++ b/server/services/tasmota/lib/features/colorSpeed.js @@ -0,0 +1,18 @@ +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); + +module.exports = { + // Tasmota matcher + keyMatcher: /^(StatusSTS|Gladys)\.Speed$/, + // Gladys feature + generateFeature: () => { + return { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.EFFECT_SPEED, + name: 'Effect speed', + read_only: false, + has_feedback: true, + min: 1, + max: 40, + }; + }, +}; diff --git a/server/services/tasmota/lib/features/colorTemperature.js b/server/services/tasmota/lib/features/colorTemperature.js new file mode 100644 index 0000000000..51cc4003b8 --- /dev/null +++ b/server/services/tasmota/lib/features/colorTemperature.js @@ -0,0 +1,18 @@ +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); + +module.exports = { + // Tasmota matcher + keyMatcher: /^(StatusSTS|Gladys)\.CT$/, + // Gladys feature + generateFeature: () => { + return { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.TEMPERATURE, + name: 'Color temperature', + read_only: false, + has_feedback: true, + min: 123, + max: 500, + }; + }, +}; diff --git a/server/services/tasmota/lib/features/counter.js b/server/services/tasmota/lib/features/counter.js new file mode 100644 index 0000000000..a4cb842360 --- /dev/null +++ b/server/services/tasmota/lib/features/counter.js @@ -0,0 +1,21 @@ +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); + +module.exports = { + // Tasmota matcher + keyMatcher: /^StatusSNS\.COUNTER\.C\d+$/, + // Gladys feature + generateFeature: (device, key) => { + const position = key.replace(/C/i, ''); + const name = `Counter ${position}`.trim(); + + return { + category: DEVICE_FEATURE_CATEGORIES.COUNTER_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.INTEGER, + name, + read_only: true, + has_feedback: false, + min: 0, + max: 10000, + }; + }, +}; diff --git a/server/services/tasmota/lib/features/dimmer.js b/server/services/tasmota/lib/features/dimmer.js new file mode 100644 index 0000000000..1328b1c074 --- /dev/null +++ b/server/services/tasmota/lib/features/dimmer.js @@ -0,0 +1,24 @@ +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); +const { LIGHT_MODULES } = require('./modules'); + +module.exports = { + // Tasmota matcher + keyMatcher: /^(StatusSTS|Gladys)\.Dimmer$/, + // Gladys feature + generateFeature: (device) => { + const lightDevice = LIGHT_MODULES.includes(device.model); + const category = lightDevice ? DEVICE_FEATURE_CATEGORIES.LIGHT : DEVICE_FEATURE_CATEGORIES.SWITCH; + const type = lightDevice ? DEVICE_FEATURE_TYPES.LIGHT.BRIGHTNESS : DEVICE_FEATURE_TYPES.SWITCH.DIMMER; + const name = lightDevice ? 'Brightness' : 'Dimmer'; + + return { + category, + type, + name, + read_only: false, + has_feedback: true, + min: 0, + max: 100, + }; + }, +}; diff --git a/server/services/tasmota/lib/features/energy.current.js b/server/services/tasmota/lib/features/energy.current.js new file mode 100644 index 0000000000..f9301d8145 --- /dev/null +++ b/server/services/tasmota/lib/features/energy.current.js @@ -0,0 +1,26 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +module.exports = { + // Tasmota matcher + keyMatcher: /^StatusSNS\.ENERGY\.Current$/, + // Gladys feature + generateFeature: () => { + return { + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.ENERGY, + name: 'Energy', + read_only: true, + has_feedback: false, + min: 0, + max: 10000, + unit: DEVICE_FEATURE_UNITS.AMPERE, + }; + }, + generateExternalId: (key) => { + return `ENERGY:${key}`; + }, +}; diff --git a/server/services/tasmota/lib/features/energy.power.js b/server/services/tasmota/lib/features/energy.power.js new file mode 100644 index 0000000000..37ce674694 --- /dev/null +++ b/server/services/tasmota/lib/features/energy.power.js @@ -0,0 +1,26 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +module.exports = { + // Tasmota matcher + keyMatcher: /^StatusSNS\.ENERGY\.Power$/, + // Gladys feature + generateFeature: () => { + return { + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.POWER, + name: 'Power', + read_only: true, + has_feedback: false, + min: 0, + max: 10000, + unit: DEVICE_FEATURE_UNITS.KILOWATT, + }; + }, + generateExternalId: (key) => { + return `ENERGY:${key}`; + }, +}; diff --git a/server/services/tasmota/lib/features/energy.voltage.js b/server/services/tasmota/lib/features/energy.voltage.js new file mode 100644 index 0000000000..619be8fc64 --- /dev/null +++ b/server/services/tasmota/lib/features/energy.voltage.js @@ -0,0 +1,30 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +module.exports = { + // Tasmota matcher + keyMatcher: /^StatusSNS\.ENERGY\.Voltage$/, + // Gladys feature + generateFeature: () => { + return { + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.VOLTAGE, + name: 'Voltage', + read_only: true, + has_feedback: false, + min: 0, + max: 10000, + unit: DEVICE_FEATURE_UNITS.VOLT, + }; + }, + generateExternalId: (key) => { + return `ENERGY:${key}`; + }, + // Gladys vs Tasmota transformers + readValue: (value) => { + return value / 1000; + }, +}; diff --git a/server/services/tasmota/lib/features/humidity.js b/server/services/tasmota/lib/features/humidity.js new file mode 100644 index 0000000000..721db080bf --- /dev/null +++ b/server/services/tasmota/lib/features/humidity.js @@ -0,0 +1,27 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +module.exports = { + // Tasmota matcher + keyMatcher: /^StatusSNS\.(DHT11|AM2301)\.Humidity$/, + // Gladys feature + generateFeature: () => { + return { + category: DEVICE_FEATURE_CATEGORIES.HUMIDITY_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.DECIMAL, + name: 'Humidity', + read_only: true, + has_feedback: false, + min: 0, + max: 100, + unit: DEVICE_FEATURE_UNITS.PERCENT, + }; + }, + generateExternalId: (key, fullKey) => { + const parts = fullKey.split('.'); + return `${parts[1]}:Humidity`; + }, +}; diff --git a/server/services/tasmota/lib/features/index.js b/server/services/tasmota/lib/features/index.js new file mode 100644 index 0000000000..74e1e9f8d6 --- /dev/null +++ b/server/services/tasmota/lib/features/index.js @@ -0,0 +1,66 @@ +// Modules +const modules = require('./modules'); + +// Features +const power = require('./power'); +const dimmer = require('./dimmer'); +const energyCurrent = require('./energy.current'); +const energyPower = require('./energy.power'); +const energyVoltage = require('./energy.voltage'); +const colorChannel = require('./colorChannel'); +const colorScheme = require('./colorScheme'); +const colorSpeed = require('./colorSpeed'); +const colorTemperature = require('./colorTemperature'); +const counter = require('./counter'); +const humidity = require('./humidity'); +const temperature = require('./temperature'); + +const FEATURE_TEMPLATES = [ + power, + dimmer, + energyCurrent, + energyPower, + energyVoltage, + colorChannel, + colorScheme, + colorSpeed, + colorTemperature, + counter, + humidity, + temperature, +]; + +const generateValue = (featureTemplate, value) => { + return typeof featureTemplate.readValue === 'function' ? featureTemplate.readValue(value) : value; +}; + +const generateExternalId = (featureTemplate, command, fullKey) => { + return typeof featureTemplate.generateExternalId === 'function' + ? featureTemplate.generateExternalId(command, fullKey) + : command; +}; + +const recursiveSearch = (message, callback, key = undefined) => { + Object.keys(message).forEach((subKey) => { + const currentObj = message[subKey]; + const fullKey = `${key ? `${key}.` : ''}${subKey}`; + + const featureTemplate = FEATURE_TEMPLATES.find((template) => { + return template.keyMatcher.test(fullKey); + }); + + if (featureTemplate) { + callback(featureTemplate, fullKey, subKey, currentObj); + } else if (typeof currentObj === 'object') { + recursiveSearch(currentObj, callback, fullKey); + } + }); +}; + +module.exports = { + MODULES: modules, + FEATURE_TEMPLATES, + recursiveSearch, + generateExternalId, + generateValue, +}; diff --git a/server/services/tasmota/lib/features/modules.js b/server/services/tasmota/lib/features/modules.js new file mode 100644 index 0000000000..86151cad50 --- /dev/null +++ b/server/services/tasmota/lib/features/modules.js @@ -0,0 +1,5 @@ +const LIGHT_MODULES = [9, 11, 26, 37]; + +module.exports = { + LIGHT_MODULES, +}; diff --git a/server/services/tasmota/lib/features/power.js b/server/services/tasmota/lib/features/power.js new file mode 100644 index 0000000000..cd1bf005bd --- /dev/null +++ b/server/services/tasmota/lib/features/power.js @@ -0,0 +1,33 @@ +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); +const { LIGHT_MODULES } = require('./modules'); + +module.exports = { + // Tasmota matcher + keyMatcher: /^(StatusSTS|Gladys)\.POWER\d*$/, + // Gladys feature + generateFeature: (device, key) => { + const lightDevice = LIGHT_MODULES.includes(device.model); + const category = lightDevice ? DEVICE_FEATURE_CATEGORIES.LIGHT : DEVICE_FEATURE_CATEGORIES.SWITCH; + const type = lightDevice ? DEVICE_FEATURE_TYPES.LIGHT.BINARY : DEVICE_FEATURE_TYPES.SWITCH.BINARY; + + const position = key.replace(/POWER/i, ''); + const name = `Switch ${position}`.trim(); + + return { + category, + type, + name, + read_only: false, + has_feedback: true, + min: 0, + max: 1, + }; + }, + // Gladys vs Tasmota transformers + readValue: (value) => { + return value === 'ON' ? 1 : 0; + }, + writeValue: (value) => { + return value ? 'ON' : 'OFF'; + }, +}; diff --git a/server/services/tasmota/lib/features/temperature.js b/server/services/tasmota/lib/features/temperature.js new file mode 100644 index 0000000000..76857ac1b6 --- /dev/null +++ b/server/services/tasmota/lib/features/temperature.js @@ -0,0 +1,27 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +module.exports = { + // Tasmota matcher + keyMatcher: /^StatusSNS\.(DHT11|AM2301)\.Temperature$/, + // Gladys feature + generateFeature: () => { + return { + category: DEVICE_FEATURE_CATEGORIES.TEMPERATURE_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.DECIMAL, + name: 'Temperature', + read_only: true, + has_feedback: false, + min: -100, + max: 200, + unit: DEVICE_FEATURE_UNITS.CELSIUS, + }; + }, + generateExternalId: (key, fullKey) => { + const parts = fullKey.split('.'); + return `${parts[1]}:Temperature`; + }, +}; diff --git a/server/services/tasmota/lib/forceScan.js b/server/services/tasmota/lib/forceScan.js new file mode 100644 index 0000000000..c4d4c324cf --- /dev/null +++ b/server/services/tasmota/lib/forceScan.js @@ -0,0 +1,14 @@ +/** + * @description Force MQTT scanning by re-subscribing to topic. + * @example + * forceScan(); + */ +function forceScan() { + // Subscribe to Tasmota + this.mqttService.device.unsubscribe('tele/+/+'); + this.mqttService.device.subscribe('tele/+/+', this.handleMqttMessage.bind(this)); +} + +module.exports = { + forceScan, +}; diff --git a/server/services/sonoff/lib/getDiscoveredDevices.js b/server/services/tasmota/lib/getDiscoveredDevices.js similarity index 75% rename from server/services/sonoff/lib/getDiscoveredDevices.js rename to server/services/tasmota/lib/getDiscoveredDevices.js index aa9b2094dc..9e7a6fe571 100644 --- a/server/services/sonoff/lib/getDiscoveredDevices.js +++ b/server/services/tasmota/lib/getDiscoveredDevices.js @@ -6,8 +6,7 @@ */ function getDiscoveredDevices() { const discovered = Object.values(this.mqttDevices).map((d) => { - const existing = this.gladys.stateManager.get('deviceByExternalId', d.external_id); - return existing || d; + return this.mergeWithExistingDevice(d); }); return discovered; diff --git a/server/services/tasmota/lib/handleMqttMessage.js b/server/services/tasmota/lib/handleMqttMessage.js new file mode 100644 index 0000000000..89f3661745 --- /dev/null +++ b/server/services/tasmota/lib/handleMqttMessage.js @@ -0,0 +1,77 @@ +const logger = require('../../../utils/logger'); +const { EVENTS } = require('../../../utils/constants'); +const { status, featureStatus, subStatus } = require('./mqtt'); + +/** + * @description Handle a new message receive in MQTT. + * @param {string} topic - MQTT topic. + * @param {Object} message - The message sent. + * @example + * handleMqttMessage('stat/tasmota/POWER', 'ON'); + */ +function handleMqttMessage(topic, message) { + const splittedTopic = topic.split('/'); + const eventType = splittedTopic[2]; + const deviceExternalId = splittedTopic[1]; + const events = []; + + switch (eventType) { + // Sensor status + case 'SENSOR': { + featureStatus(deviceExternalId, message, this.gladys.event, 'StatusSNS'); + break; + } + // Device global status + case 'STATUS': { + delete this.mqttDevices[deviceExternalId]; + const device = status(deviceExternalId, message, this.serviceId); + this.pendingMqttDevices[deviceExternalId] = device; + this.mqttService.device.publish(`cmnd/${deviceExternalId}/STATUS`, '11'); + break; + } + // Device secondary features + case 'STATUS8': { + let device = this.pendingMqttDevices[deviceExternalId]; + if (device) { + subStatus(device, message, this.gladys.event); + device = this.mergeWithExistingDevice(device); + + this.mqttDevices[deviceExternalId] = device; + delete this.pendingMqttDevices[deviceExternalId]; + + this.notifyNewDevice(device); + } + break; + } + // Device primary features + case 'STATUS11': { + const device = this.pendingMqttDevices[deviceExternalId]; + if (device) { + subStatus(device, message, this.gladys.event); + // Ask for secondary features + this.mqttService.device.publish(`cmnd/${deviceExternalId}/STATUS`, '8'); + } + break; + } + case 'RESULT': + case 'STATE': { + featureStatus(deviceExternalId, message, this.gladys.event, 'StatusSTS'); + break; + } + // Online status + case 'LWT': { + this.mqttService.device.publish(`cmnd/${deviceExternalId}/status`); + break; + } + default: { + logger.debug(`MQTT : Tasmota topic "${topic}" not handled.`); + } + } + + events.forEach((event) => this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, event)); + return null; +} + +module.exports = { + handleMqttMessage, +}; diff --git a/server/services/tasmota/lib/index.js b/server/services/tasmota/lib/index.js new file mode 100644 index 0000000000..aad801c431 --- /dev/null +++ b/server/services/tasmota/lib/index.js @@ -0,0 +1,34 @@ +const { connect } = require('./connect'); +const { disconnect } = require('./disconnect'); +const { handleMqttMessage } = require('./handleMqttMessage'); +const { getDiscoveredDevices } = require('./getDiscoveredDevices'); +const { setValue } = require('./setValue'); +const { forceScan } = require('./forceScan'); +const { mergeWithExistingDevice } = require('./mergeWithExistingDevice'); +const { notifyNewDevice } = require('./notifyNewDevice'); + +/** + * @description Add ability to connect to Tasmota devices. + * @param {Object} gladys - Gladys instance. + * @param {string} serviceId - UUID of the service in DB. + * @example + * const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + */ +const TasmotaHandler = function TasmotaHandler(gladys, serviceId) { + this.gladys = gladys; + this.serviceId = serviceId; + this.mqttService = null; + this.mqttDevices = {}; + this.pendingMqttDevices = {}; +}; + +TasmotaHandler.prototype.connect = connect; +TasmotaHandler.prototype.disconnect = disconnect; +TasmotaHandler.prototype.handleMqttMessage = handleMqttMessage; +TasmotaHandler.prototype.getDiscoveredDevices = getDiscoveredDevices; +TasmotaHandler.prototype.setValue = setValue; +TasmotaHandler.prototype.forceScan = forceScan; +TasmotaHandler.prototype.mergeWithExistingDevice = mergeWithExistingDevice; +TasmotaHandler.prototype.notifyNewDevice = notifyNewDevice; + +module.exports = TasmotaHandler; diff --git a/server/services/tasmota/lib/mergeWithExistingDevice.js b/server/services/tasmota/lib/mergeWithExistingDevice.js new file mode 100644 index 0000000000..ef06c9772d --- /dev/null +++ b/server/services/tasmota/lib/mergeWithExistingDevice.js @@ -0,0 +1,38 @@ +const matchFeature = (device, feature) => { + return device.features.findIndex((f) => f.external_id === feature.external_id); +}; + +/** + * @description Get all discovered devices, and if device already created, the Gladys device. + * @param {Object} mqttDevice - Discovered device. + * @returns {Object} Device merged with Gladys existing one. + * @example + * mergeWithExistingDevice(discorveredDevice) + */ +function mergeWithExistingDevice(mqttDevice) { + const existing = this.gladys.stateManager.get('deviceByExternalId', mqttDevice.external_id); + if (existing) { + const device = { ...existing, ...mqttDevice }; + const { features } = device; + const featureLength = features.length; + + let updatable = existing.features.length !== featureLength; + let i = 0; + while (!updatable && features[i]) { + updatable = matchFeature(existing, features[i]) < 0; + i += 1; + } + + if (updatable) { + device.updatable = updatable; + } + + return device; + } + + return mqttDevice; +} + +module.exports = { + mergeWithExistingDevice, +}; diff --git a/server/services/tasmota/lib/mqtt/featureStatus.js b/server/services/tasmota/lib/mqtt/featureStatus.js new file mode 100644 index 0000000000..2de8a21893 --- /dev/null +++ b/server/services/tasmota/lib/mqtt/featureStatus.js @@ -0,0 +1,37 @@ +const { EVENTS } = require('../../../../utils/constants'); +const { recursiveSearch, generateExternalId, generateValue } = require('../features'); + +const sendEvent = (gladysEvent, deviceExternalId, featureTemplate, fullKey, command, value) => { + const featureExternalId = generateExternalId(featureTemplate, command, fullKey); + + gladysEvent.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `tasmota:${deviceExternalId}:${featureExternalId}`, + state: generateValue(featureTemplate, value), + }); +}; + +/** + * @description Handle Tasmota 'stat/+/SENSOR' topics. + * @param {string} deviceExternalId - Device external id. + * @param {string} message - MQTT message. + * @param {Object} gladysEvent - Gladys event emitter. + * @param {string} key - Default object key. + * @returns {any} NULL. + * @example + * sensor('tasmota:sonoff-plug', '{"key": "value"}', gladysEvent); + */ +function featureStatus(deviceExternalId, message, gladysEvent, key) { + const sensorMsg = JSON.parse(message); + + recursiveSearch( + sensorMsg, + (featureTemplate, fullKey, command, value) => + sendEvent(gladysEvent, deviceExternalId, featureTemplate, fullKey, command, value), + key, + ); + return null; +} + +module.exports = { + featureStatus, +}; diff --git a/server/services/tasmota/lib/mqtt/index.js b/server/services/tasmota/lib/mqtt/index.js new file mode 100644 index 0000000000..11a03343fa --- /dev/null +++ b/server/services/tasmota/lib/mqtt/index.js @@ -0,0 +1,9 @@ +const { status } = require('./status'); +const { subStatus } = require('./subStatus'); +const { featureStatus } = require('./featureStatus'); + +module.exports = { + status, + subStatus, + featureStatus, +}; diff --git a/server/services/tasmota/lib/mqtt/status.js b/server/services/tasmota/lib/mqtt/status.js new file mode 100644 index 0000000000..f8ce5757a7 --- /dev/null +++ b/server/services/tasmota/lib/mqtt/status.js @@ -0,0 +1,34 @@ +const { addSelector } = require('../../../../utils/addSelector'); + +/** + * @description Handle Tasmota 'stat/+/STATUS' topics. + * @param {string} deviceExternalId - Device external id. + * @param {string} message - MQTT message. + * @param {Object} serviceId - Service ID. + * @example + * status('tasmota:tasmota-plug', '{"key": "value"}', 'service-id'); + */ +function status(deviceExternalId, message, serviceId) { + const statusMsg = JSON.parse(message); + const friendlyName = statusMsg.Status.FriendlyName[0]; + const moduleId = statusMsg.Status.Module; + + const externalId = `tasmota:${deviceExternalId}`; + const device = { + name: friendlyName, + external_id: externalId, + selector: externalId, + features: [], + model: moduleId, + service_id: serviceId, + should_poll: false, + }; + + addSelector(device); + + return device; +} + +module.exports = { + status, +}; diff --git a/server/services/tasmota/lib/mqtt/subStatus.js b/server/services/tasmota/lib/mqtt/subStatus.js new file mode 100644 index 0000000000..8bc987294e --- /dev/null +++ b/server/services/tasmota/lib/mqtt/subStatus.js @@ -0,0 +1,62 @@ +const { EVENTS } = require('../../../../utils/constants'); +const { addSelector } = require('../../../../utils/addSelector'); +const logger = require('../../../../utils/logger'); +const { recursiveSearch, generateExternalId, generateValue } = require('../features'); + +const addFeature = (device, featureTemplate, fullKey, command, value) => { + const featureExternalId = generateExternalId(featureTemplate, command, fullKey); + const externalId = `${device.external_id}:${featureExternalId}`; + const existingFeature = device.features.find((f) => f.external_id === externalId); + + if (existingFeature) { + logger.debug(`Tasmota: duplicated feature handled for ${externalId}`); + } else { + const generatedFeature = featureTemplate.generateFeature(device, command, value); + + if (generatedFeature) { + const convertedValue = generateValue(featureTemplate, value); + + const feature = { + ...generatedFeature, + external_id: externalId, + selector: externalId, + last_value: convertedValue, + }; + + addSelector(feature); + + device.features.push(feature); + return feature; + } + } + + return null; +}; + +/** + * @description Handle Tasmota 'stat/+/STATUS' topics to create device features. + * @param {Object} device - Relative device. + * @param {string} message - MQTT message. + * @param {Object} gladysEvent - Gladys event manager. + * @returns {any} NULL. + * @example + * subStatus(device, '{"key": "value"}'); + */ +function subStatus(device, message, gladysEvent) { + const statusMsg = JSON.parse(message); + + recursiveSearch(statusMsg, (featureTemplate, fullKey, command, value) => { + const feature = addFeature(device, featureTemplate, fullKey, command, value); + if (feature) { + gladysEvent.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: feature.last_value, + }); + } + }); + return null; +} + +module.exports = { + subStatus, +}; diff --git a/server/services/tasmota/lib/notifyNewDevice.js b/server/services/tasmota/lib/notifyNewDevice.js new file mode 100644 index 0000000000..42458a5dd4 --- /dev/null +++ b/server/services/tasmota/lib/notifyNewDevice.js @@ -0,0 +1,18 @@ +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../utils/constants'); + +/** + * @description Get all discovered devices, and if device already created, the Gladys device. + * @param {Object} mqttDevice - Discovered device. + * @example + * notifyNewDevice(discorveredDevice) + */ +function notifyNewDevice(mqttDevice) { + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: mqttDevice, + }); +} + +module.exports = { + notifyNewDevice, +}; diff --git a/server/services/tasmota/lib/setValue.js b/server/services/tasmota/lib/setValue.js new file mode 100644 index 0000000000..2a0b08546d --- /dev/null +++ b/server/services/tasmota/lib/setValue.js @@ -0,0 +1,40 @@ +const { BadParameters } = require('../../../utils/coreErrors'); +const { FEATURE_TEMPLATES } = require('./features'); + +/** + * @description Send the new device value over MQTT. + * @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); + */ +function setValue(device, deviceFeature, value) { + const externalId = deviceFeature.external_id; + const [prefix, topic, command] = deviceFeature.external_id.split(':'); + + if (prefix !== 'tasmota') { + throw new BadParameters(`Tasmota device external_id is invalid: "${externalId}" should starts with "tasmota:"`); + } + if (!topic || topic.length === 0) { + throw new BadParameters(`Tasmota device external_id is invalid: "${externalId}" have no MQTT topic`); + } + + const gladysKey = `Gladys.${command}`; + const featureTemplate = FEATURE_TEMPLATES.find((template) => { + return template.keyMatcher.test(gladysKey); + }); + + if (featureTemplate) { + const mqttValue = typeof featureTemplate.writeValue === 'function' ? featureTemplate.writeValue(value) : value; + + // Send message to Tasmota topics + this.mqttService.device.publish(`cmnd/${topic}/${command}`, `${mqttValue}`); + } else { + throw new BadParameters(`Tasmota device external_id is not managed: "${externalId}" have no MQTT topic`); + } +} + +module.exports = { + setValue, +}; diff --git a/server/services/sonoff/package-lock.json b/server/services/tasmota/package-lock.json similarity index 92% rename from server/services/sonoff/package-lock.json rename to server/services/tasmota/package-lock.json index 051951eec8..3fe7662fed 100644 --- a/server/services/sonoff/package-lock.json +++ b/server/services/tasmota/package-lock.json @@ -1,5 +1,5 @@ { - "name": "gladys-sonoff", + "name": "gladys-tasmota", "version": "1.0.0", "lockfileVersion": 1, "requires": true, diff --git a/server/services/sonoff/package.json b/server/services/tasmota/package.json similarity index 87% rename from server/services/sonoff/package.json rename to server/services/tasmota/package.json index 1b8f8ea102..f769ddbb63 100644 --- a/server/services/sonoff/package.json +++ b/server/services/tasmota/package.json @@ -1,5 +1,5 @@ { - "name": "gladys-sonoff", + "name": "gladys-tasmota", "version": "1.0.0", "main": "index.js", "os": [ diff --git a/server/test/services/sonoff/index.test.js b/server/test/services/sonoff/index.test.js deleted file mode 100644 index f3014dca43..0000000000 --- a/server/test/services/sonoff/index.test.js +++ /dev/null @@ -1,30 +0,0 @@ -const sinon = require('sinon'); - -const { assert } = sinon; -const proxyquire = require('proxyquire').noCallThru(); - -const SonoffMock = require('./sonoff.mock.test'); - -const SonoffService = proxyquire('../../../services/sonoff/index', { - './lib': SonoffMock, -}); - -describe('SonoffService', () => { - const sonoffService = SonoffService({}, 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'); - - beforeEach(() => { - sinon.reset(); - }); - - it('should start service', async () => { - await sonoffService.start(); - assert.calledOnce(sonoffService.device.connect); - assert.notCalled(sonoffService.device.disconnect); - }); - - it('should stop service', async () => { - sonoffService.stop(); - assert.notCalled(sonoffService.device.connect); - assert.calledOnce(sonoffService.device.disconnect); - }); -}); diff --git a/server/test/services/sonoff/lib/connect.test.js b/server/test/services/sonoff/lib/connect.test.js deleted file mode 100644 index aea2cafeff..0000000000 --- a/server/test/services/sonoff/lib/connect.test.js +++ /dev/null @@ -1,33 +0,0 @@ -const sinon = require('sinon'); - -const { fake, assert } = sinon; -const SonoffHandler = require('../../../../services/sonoff/lib'); - -const mqttService = { - device: { - subscribe: fake.returns(null), - }, -}; -const gladys = { - service: { - getService: fake.returns(mqttService), - }, -}; - -describe('SonoffHandler - connect', () => { - const sonoffHandler = new SonoffHandler(gladys, 'service-uuid-random'); - sinon.spy(sonoffHandler, 'handleMqttMessage'); - - beforeEach(() => { - sinon.reset(); - }); - - it('connect with subscription', () => { - sonoffHandler.connect(); - - assert.calledWith(gladys.service.getService, 'mqtt'); - assert.callCount(mqttService.device.subscribe, 2); - mqttService.device.subscribe.firstCall.calledWith('stat/+/+', sonoffHandler.handleMqttMessage.bind(sonoffHandler)); - mqttService.device.subscribe.secondCall.calledWith('tele/+/+', sonoffHandler.handleMqttMessage.bind(sonoffHandler)); - }); -}); diff --git a/server/test/services/sonoff/lib/getDiscoveredDevices.test.js b/server/test/services/sonoff/lib/getDiscoveredDevices.test.js deleted file mode 100644 index a4abba7f67..0000000000 --- a/server/test/services/sonoff/lib/getDiscoveredDevices.test.js +++ /dev/null @@ -1,53 +0,0 @@ -const { expect } = require('chai'); -const sinon = require('sinon'); - -const SonoffHandler = require('../../../../services/sonoff/lib'); - -const existingDevice = { - external_id: 'alreadyExists', - name: 'alreadyExists', -}; - -const gladys = { - stateManager: { - get: (key, externalId) => { - if (externalId === 'alreadyExists') { - return existingDevice; - } - return undefined; - }, - }, -}; - -describe('SonoffHandler - getDiscoveredDevices', () => { - const sonoffHandler = new SonoffHandler(gladys, 'service-uuid-random'); - sinon.spy(sonoffHandler, 'handleMqttMessage'); - - beforeEach(() => { - sinon.reset(); - sonoffHandler.mqttDevices = {}; - }); - - it('nothing discovered', () => { - const result = sonoffHandler.getDiscoveredDevices(); - expect(result).to.be.lengthOf(0); - }); - - it('discovered already in Gladys', () => { - sonoffHandler.mqttDevices.alreadyExists = { - external_id: 'alreadyExists', - }; - const result = sonoffHandler.getDiscoveredDevices(); - expect(result).to.be.lengthOf(1); - expect(result).deep.eq([existingDevice]); - }); - - it('discovered not already in Gladys', () => { - sonoffHandler.mqttDevices.notAlreadyExists = { - external_id: 'notAlreadyExists', - }; - const result = sonoffHandler.getDiscoveredDevices(); - expect(result).to.be.lengthOf(1); - expect(result).deep.eq([sonoffHandler.mqttDevices.notAlreadyExists]); - }); -}); diff --git a/server/test/services/sonoff/lib/handleMqttMessage.test.js b/server/test/services/sonoff/lib/handleMqttMessage.test.js deleted file mode 100644 index 73b30d4cda..0000000000 --- a/server/test/services/sonoff/lib/handleMqttMessage.test.js +++ /dev/null @@ -1,148 +0,0 @@ -const sinon = require('sinon'); - -const { assert, fake } = sinon; -const { EVENTS } = require('../../../../utils/constants'); -const SonoffHandler = require('../../../../services/sonoff/lib'); - -const gladys = { - variable: { - getValue: fake.resolves('result'), - }, - event: { - emit: fake.returns(null), - }, -}; -const mqttService = { - device: { - publish: fake.returns(null), - }, -}; - -describe('Mqtt handle message', () => { - const sonoffHandler = new SonoffHandler(gladys, 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'); - sonoffHandler.mqttService = mqttService; - - beforeEach(async () => { - sinon.reset(); - }); - - it('should change SONOFF power state to ON (POWER topic)', () => { - sonoffHandler.handleMqttMessage('stat/my_device/POWER', 'ON'); - - const expectedEvent = { - device_feature_external_id: `sonoff:my_device:switch:binary`, - state: 1, - }; - - assert.notCalled(mqttService.device.publish); - assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); - }); - - it('should change SONOFF power state to OFF (POWER topic)', () => { - sonoffHandler.handleMqttMessage('stat/my_device/POWER', 'OFF'); - - const expectedEvent = { - device_feature_external_id: `sonoff:my_device:switch:binary`, - state: 0, - }; - - assert.notCalled(mqttService.device.publish); - assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); - }); - - it('should change SONOFF sensor state to 125 (SENSOR topic)', () => { - sonoffHandler.handleMqttMessage('tele/my_device/SENSOR', '{ "ENERGY": { "Current": 125 }}'); - - const expectedEvent = { - device_feature_external_id: `sonoff:my_device:switch:power`, - state: 125, - }; - - assert.notCalled(mqttService.device.publish); - assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); - }); - - it('should change SONOFF power state to ON (STATUS topic unknown device)', () => { - sonoffHandler.handleMqttMessage('stat/my_device/STATUS', '{ "Status": { "Power": 1, "FriendlyName": ["name"] }}'); - - assert.notCalled(mqttService.device.publish); - assert.notCalled(gladys.event.emit); - }); - - it('should change SONOFF power state to ON (STATUS topic)', () => { - sonoffHandler.handleMqttMessage( - 'stat/my_device/STATUS', - '{ "Status": {"Module": 1, "Power": 1, "FriendlyName": ["name"] }}', - ); - - const expectedEvent = { - device_feature_external_id: `sonoff:my_device:switch:binary`, - state: 1, - }; - - assert.notCalled(mqttService.device.publish); - assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); - }); - - it('should change SONOFF power state to ON (STATE topic)', () => { - sonoffHandler.handleMqttMessage('stat/my_device/STATE', '{ "POWER": "ON"}'); - - const expectedEvent = { - device_feature_external_id: `sonoff:my_device:switch:binary`, - state: 1, - }; - - assert.notCalled(mqttService.device.publish); - assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); - }); - - it('should change SONOFF power state to OFF (STATE topic)', () => { - sonoffHandler.handleMqttMessage('stat/my_device/STATE', '{ "POWER": "OFF"}'); - - const expectedEvent = { - device_feature_external_id: `sonoff:my_device:switch:binary`, - state: 0, - }; - - assert.notCalled(mqttService.device.publish); - assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); - }); - - it('should change SONOFF power state to ON (RESULT topic)', () => { - sonoffHandler.handleMqttMessage('stat/my_device/RESULT', '{ "POWER": "ON"}'); - - const expectedEvent = { - device_feature_external_id: `sonoff:my_device:switch:binary`, - state: 1, - }; - - assert.notCalled(mqttService.device.publish); - assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); - }); - - it('should change SONOFF power state to OFF (RESULT topic)', () => { - sonoffHandler.handleMqttMessage('stat/my_device/RESULT', '{ "POWER": "OFF"}'); - - const expectedEvent = { - device_feature_external_id: `sonoff:my_device:switch:binary`, - state: 0, - }; - - assert.notCalled(mqttService.device.publish); - assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); - }); - - it('should ask for SONOFF status (LWT topic)', () => { - sonoffHandler.handleMqttMessage('stat/my_device/LWT', 'anything'); - - assert.calledWith(mqttService.device.publish, 'cmnd/my_device/status'); - assert.notCalled(gladys.event.emit); - }); - - it('should do nothing on unkown SONOFF topic', () => { - sonoffHandler.handleMqttMessage('stat/my_device/UNKOWN', '{ "POWER": "ON"}'); - - assert.notCalled(mqttService.device.publish); - assert.notCalled(gladys.event.emit); - }); -}); diff --git a/server/test/services/sonoff/lib/setValue.test.js b/server/test/services/sonoff/lib/setValue.test.js deleted file mode 100644 index 6ff58d785b..0000000000 --- a/server/test/services/sonoff/lib/setValue.test.js +++ /dev/null @@ -1,79 +0,0 @@ -const sinon = require('sinon'); - -const { fake, assert } = sinon; -const { expect } = require('chai'); -const SonoffHandler = require('../../../../services/sonoff/lib'); - -const mqttService = { - device: { - publish: fake.returns(null), - }, -}; -const gladys = {}; - -describe('SonoffHandler - setValue', () => { - const sonoffHandler = new SonoffHandler(gladys, 'service-uuid-random'); - sonoffHandler.mqttService = mqttService; - - beforeEach(() => { - sinon.reset(); - }); - - it('publish through invalid topic', () => { - const device = { - external_id: 'deviceInvalidTopic', - }; - const feature = undefined; - const value = 1; - - try { - sonoffHandler.setValue(device, feature, value); - assert.fail('Should ends on error'); - } catch (e) { - assert.notCalled(mqttService.device.publish); - expect(e.message).to.eq( - 'Sonoff device external_id is invalid : "deviceInvalidTopic" should starts with "sonoff:"', - ); - } - }); - - it('publish through null topic', () => { - const device = { - external_id: 'sonoff:', - }; - const feature = undefined; - const value = 1; - - try { - sonoffHandler.setValue(device, feature, value); - assert.fail('Should ends on error'); - } catch (e) { - assert.notCalled(mqttService.device.publish); - expect(e.message).to.eq('Sonoff device external_id is invalid : "sonoff:" have no MQTT topic'); - } - }); - - it('publish ON through valid topic', () => { - const device = { - external_id: 'sonoff:deviceTopic', - }; - const feature = undefined; - const value = 1; - - sonoffHandler.setValue(device, feature, value); - - assert.calledWith(mqttService.device.publish, 'cmnd/deviceTopic/power', 'ON'); - }); - - it('publish OFF through valid topic', () => { - const device = { - external_id: 'sonoff:deviceTopic', - }; - const feature = undefined; - const value = 0; - - sonoffHandler.setValue(device, feature, value); - - assert.calledWith(mqttService.device.publish, 'cmnd/deviceTopic/power', 'OFF'); - }); -}); diff --git a/server/test/services/sonoff/models/plug_s26.test.js b/server/test/services/sonoff/models/plug_s26.test.js deleted file mode 100644 index 1a81de5db9..0000000000 --- a/server/test/services/sonoff/models/plug_s26.test.js +++ /dev/null @@ -1,29 +0,0 @@ -const { expect } = require('chai'); - -const models = require('../../../../services/sonoff/models'); -const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); - -const modelId = 8; - -describe('SonoffService - Model - Plug S2x', () => { - it('get model for Sonoff Plug S2x', () => { - const model = models[modelId].getModel(); - - expect(model).to.eq('sonoff-s2x'); - }); - - it('get features for Sonoff Plug S2x', () => { - const features = models[modelId].getFeatures(); - - expect(features).to.deep.eq([ - { - category: DEVICE_FEATURE_CATEGORIES.SWITCH, - type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, - read_only: false, - has_feedback: true, - min: 0, - max: 1, - }, - ]); - }); -}); diff --git a/server/test/services/sonoff/models/switch_basic.test.js b/server/test/services/sonoff/models/switch_basic.test.js deleted file mode 100644 index 657560e6bd..0000000000 --- a/server/test/services/sonoff/models/switch_basic.test.js +++ /dev/null @@ -1,29 +0,0 @@ -const { expect } = require('chai'); - -const models = require('../../../../services/sonoff/models'); -const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); - -const modelId = 1; - -describe('SonoffService - Model - Basic', () => { - it('get model for Sonoff Basic', () => { - const model = models[modelId].getModel(); - - expect(model).to.eq('sonoff-basic'); - }); - - it('get features for Sonoff Basic', () => { - const features = models[modelId].getFeatures(); - - expect(features).to.deep.eq([ - { - category: DEVICE_FEATURE_CATEGORIES.SWITCH, - type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, - read_only: false, - has_feedback: true, - min: 0, - max: 1, - }, - ]); - }); -}); diff --git a/server/test/services/sonoff/models/switch_pow.test.js b/server/test/services/sonoff/models/switch_pow.test.js deleted file mode 100644 index 71762b15d3..0000000000 --- a/server/test/services/sonoff/models/switch_pow.test.js +++ /dev/null @@ -1,38 +0,0 @@ -const { expect } = require('chai'); - -const models = require('../../../../services/sonoff/models'); -const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); - -const modelId = 6; - -describe('SonoffService - Model - Pow', () => { - it('get model for Sonoff Pow', () => { - const model = models[modelId].getModel(); - - expect(model).to.eq('sonoff-pow'); - }); - - it('get features for Sonoff Pow', () => { - const features = models[modelId].getFeatures(); - - expect(features).to.deep.eq([ - { - category: DEVICE_FEATURE_CATEGORIES.SWITCH, - type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, - read_only: false, - has_feedback: true, - min: 0, - max: 1, - }, - { - category: DEVICE_FEATURE_CATEGORIES.SWITCH, - type: DEVICE_FEATURE_TYPES.SWITCH.POWER, - read_only: true, - has_feedback: false, - min: 0, - max: 10000, - unit: 'A', - }, - ]); - }); -}); diff --git a/server/test/services/sonoff/models/unknown.test.js b/server/test/services/sonoff/models/unknown.test.js deleted file mode 100644 index 9e7f9347e4..0000000000 --- a/server/test/services/sonoff/models/unknown.test.js +++ /dev/null @@ -1,11 +0,0 @@ -const { expect } = require('chai'); - -const models = require('../../../../services/sonoff/models'); - -describe('SonoffService - unknown model', () => { - it('get model for unkown device', () => { - const params = models[-1]; - - expect(params).to.eq(undefined); - }); -}); diff --git a/server/test/services/sonoff/sonoff.mock.test.js b/server/test/services/sonoff/sonoff.mock.test.js deleted file mode 100644 index e6cabfc94b..0000000000 --- a/server/test/services/sonoff/sonoff.mock.test.js +++ /dev/null @@ -1,14 +0,0 @@ -const { fake } = require('sinon'); - -const SonoffHandler = function SonoffHandler(gladys, serviceId) { - this.gladys = gladys; - this.serviceId = serviceId; - this.mqttService = null; - this.mqttDevices = {}; -}; - -SonoffHandler.prototype.connect = fake.returns(null); -SonoffHandler.prototype.disconnect = fake.returns(null); -SonoffHandler.prototype.handleMqttMessage = fake.returns(null); - -module.exports = SonoffHandler; diff --git a/server/test/services/sonoff/api/sonoffcontroler.test.js b/server/test/services/tasmota/api/tasmota.controller.test.js similarity index 50% rename from server/test/services/sonoff/api/sonoffcontroler.test.js rename to server/test/services/tasmota/api/tasmota.controller.test.js index bdcf05ac83..46e196d111 100644 --- a/server/test/services/sonoff/api/sonoffcontroler.test.js +++ b/server/test/services/tasmota/api/tasmota.controller.test.js @@ -1,16 +1,16 @@ const { assert, fake } = require('sinon'); -const SonoffController = require('../../../../services/sonoff/api/sonoff.controller'); +const TasmotaController = require('../../../../services/tasmota/api/tasmota.controller'); const discoveredDevices = [{ device: 'first' }, { device: 'second' }]; -const sonoffHandler = { +const tasmotaHandler = { getDiscoveredDevices: fake.returns(discoveredDevices), }; -describe('GET /api/v1/service/sonoff/discover', () => { +describe('GET /api/v1/service/tasmota/discover', () => { let controller; beforeEach(() => { - controller = SonoffController(sonoffHandler); + controller = TasmotaController(tasmotaHandler); }); it('Discover', () => { @@ -18,8 +18,8 @@ describe('GET /api/v1/service/sonoff/discover', () => { json: fake.returns(null), }; - controller['get /api/v1/service/sonoff/discover'].controller(undefined, res); - assert.calledOnce(sonoffHandler.getDiscoveredDevices); + controller['get /api/v1/service/tasmota/discover'].controller(undefined, res); + assert.calledOnce(tasmotaHandler.getDiscoveredDevices); assert.calledWith(res.json, discoveredDevices); }); }); diff --git a/server/test/services/tasmota/index.test.js b/server/test/services/tasmota/index.test.js new file mode 100644 index 0000000000..e02a30c6c1 --- /dev/null +++ b/server/test/services/tasmota/index.test.js @@ -0,0 +1,30 @@ +const sinon = require('sinon'); + +const { assert } = sinon; +const proxyquire = require('proxyquire').noCallThru(); + +const TasmotaMock = require('./tasmota.mock.test'); + +const TasmotaService = proxyquire('../../../services/tasmota/index', { + './lib': TasmotaMock, +}); + +describe('TasmotaService', () => { + const tasmotaService = TasmotaService({}, 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'); + + beforeEach(() => { + sinon.reset(); + }); + + it('should start service', async () => { + await tasmotaService.start(); + assert.calledOnce(tasmotaService.device.connect); + assert.notCalled(tasmotaService.device.disconnect); + }); + + it('should stop service', async () => { + tasmotaService.stop(); + assert.notCalled(tasmotaService.device.connect); + assert.calledOnce(tasmotaService.device.disconnect); + }); +}); diff --git a/server/test/services/tasmota/lib/connect.test.js b/server/test/services/tasmota/lib/connect.test.js new file mode 100644 index 0000000000..f05248ff1d --- /dev/null +++ b/server/test/services/tasmota/lib/connect.test.js @@ -0,0 +1,39 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../services/tasmota/lib'); + +const mqttService = { + device: { + subscribe: fake.returns(null), + }, +}; +const gladys = { + service: { + getService: fake.returns(mqttService), + }, +}; + +describe('TasmotaHandler - connect', () => { + const tasmotaHandler = new TasmotaHandler(gladys, 'service-uuid-random'); + sinon.spy(tasmotaHandler, 'handleMqttMessage'); + + beforeEach(() => { + sinon.reset(); + }); + + it('connect with subscription', () => { + tasmotaHandler.connect(); + + assert.calledWith(gladys.service.getService, 'mqtt'); + assert.callCount(mqttService.device.subscribe, 2); + mqttService.device.subscribe.firstCall.calledWith( + 'stat/+/+', + tasmotaHandler.handleMqttMessage.bind(tasmotaHandler), + ); + mqttService.device.subscribe.secondCall.calledWith( + 'tele/+/+', + tasmotaHandler.handleMqttMessage.bind(tasmotaHandler), + ); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/AM2301.json b/server/test/services/tasmota/lib/device-creation/AM2301.json new file mode 100644 index 0000000000..cba885ae7e --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/AM2301.json @@ -0,0 +1,45 @@ +{ + "STATUS": { + "Status": { + "ButtonRetain": 0, + "ButtonTopic": "0", + "FriendlyName": ["Tasmota"], + "LedMask": "FFFF", + "LedState": 1, + "Module": 18, + "Power": 0, + "PowerOnState": 1, + "PowerRetain": 0, + "SaveData": 1, + "SaveState": 1, + "SensorRetain": 0, + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "SwitchRetain": 0, + "SwitchTopic": "0", + "Topic": "tasmota" + } + }, + "STATUS11": { + "StatusSTS": { + "MqttCount": 1, + "Sleep": 50, + "SleepMode": "Dynamic", + "Wifi": { + "AP": 1, + "BSSId": "EC:BE:DD:85:1F:E0", + "Channel": 1, + "LinkCount": 1, + "SSId": "ALEX-NETWORK" + } + } + }, + "STATUS8": { + "StatusSNS": { + "AM2301": { + "Humidity": 65, + "Temperature": 23 + }, + "TempUnit": "C" + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/DHT11.json b/server/test/services/tasmota/lib/device-creation/DHT11.json new file mode 100644 index 0000000000..1dc8a535f1 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/DHT11.json @@ -0,0 +1,45 @@ +{ + "STATUS": { + "Status": { + "ButtonRetain": 0, + "ButtonTopic": "0", + "FriendlyName": ["Tasmota"], + "LedMask": "FFFF", + "LedState": 1, + "Module": 18, + "Power": 0, + "PowerOnState": 1, + "PowerRetain": 0, + "SaveData": 1, + "SaveState": 1, + "SensorRetain": 0, + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "SwitchRetain": 0, + "SwitchTopic": "0", + "Topic": "tasmota" + } + }, + "STATUS11": { + "StatusSTS": { + "MqttCount": 1, + "Sleep": 50, + "SleepMode": "Dynamic", + "Wifi": { + "AP": 1, + "BSSId": "EC:BE:DD:85:1F:E0", + "Channel": 1, + "LinkCount": 1, + "SSId": "ALEX-NETWORK" + } + } + }, + "STATUS8": { + "StatusSNS": { + "DHT11": { + "Humidity": 65, + "Temperature": 23 + }, + "TempUnit": "C" + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/colorChannel-1.json b/server/test/services/tasmota/lib/device-creation/colorChannel-1.json new file mode 100644 index 0000000000..2b71781763 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorChannel-1.json @@ -0,0 +1,50 @@ +{ + "STATUS": { + "Status": { + "Module": 13, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:02:53" + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "Channel": [100], + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/colorChannel-1.test.js b/server/test/services/tasmota/lib/device-creation/colorChannel-1.test.js new file mode 100644 index 0000000000..dc4c233eed --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorChannel-1.test.js @@ -0,0 +1,100 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../../utils/constants'); + +const messages = require('./colorChannel-1.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with COLOR Channel #WW feature', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + assert.notCalled(gladys.stateManager.get); + assert.notCalled(gladys.event.emit); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/colorChannel-2.json b/server/test/services/tasmota/lib/device-creation/colorChannel-2.json new file mode 100644 index 0000000000..4208ae1e20 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorChannel-2.json @@ -0,0 +1,50 @@ +{ + "STATUS": { + "Status": { + "Module": 13, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:02:53" + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "Channel": [100, 100], + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/colorChannel-2.test.js b/server/test/services/tasmota/lib/device-creation/colorChannel-2.test.js new file mode 100644 index 0000000000..2a4be075c7 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorChannel-2.test.js @@ -0,0 +1,99 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../../utils/constants'); + +const messages = require('./colorChannel-2.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with COLOR Channel #CWWW feature', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/colorChannel-3.json b/server/test/services/tasmota/lib/device-creation/colorChannel-3.json new file mode 100644 index 0000000000..6ce7ac24c3 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorChannel-3.json @@ -0,0 +1,50 @@ +{ + "STATUS": { + "Status": { + "Module": 13, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:02:53" + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "Channel": [100, 100, 100], + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/colorChannel-3.test.js b/server/test/services/tasmota/lib/device-creation/colorChannel-3.test.js new file mode 100644 index 0000000000..fcdf66f108 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorChannel-3.test.js @@ -0,0 +1,134 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./colorChannel-3.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with COLOR Channel #RRGGBB feature', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.COLOR, + external_id: 'tasmota:tasmota-device-topic:Channel', + selector: 'tasmota-tasmota-device-topic-channel', + name: 'Color', + read_only: false, + has_feedback: true, + min: 0, + max: 16777215, + last_value: 16777215, + }, + ], + }, + }); + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:Channel', + state: 16777215, + }); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.COLOR, + external_id: 'tasmota:tasmota-device-topic:Channel', + selector: 'tasmota-tasmota-device-topic-channel', + name: 'Color', + read_only: false, + has_feedback: true, + min: 0, + max: 16777215, + last_value: 16777215, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/colorChannel-4.json b/server/test/services/tasmota/lib/device-creation/colorChannel-4.json new file mode 100644 index 0000000000..7ce64725e6 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorChannel-4.json @@ -0,0 +1,50 @@ +{ + "STATUS": { + "Status": { + "Module": 13, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:02:53" + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "Channel": [100, 100, 100, 100], + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/colorChannel-4.test.js b/server/test/services/tasmota/lib/device-creation/colorChannel-4.test.js new file mode 100644 index 0000000000..d7ee9f54c1 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorChannel-4.test.js @@ -0,0 +1,133 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./colorChannel-4.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with COLOR Channel #RRGGBBWW feature', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.COLOR, + external_id: 'tasmota:tasmota-device-topic:Channel', + selector: 'tasmota-tasmota-device-topic-channel', + name: 'Color', + read_only: false, + has_feedback: true, + min: 0, + max: 16777215, + last_value: 16777215, + }, + ], + }, + }); + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:Channel', + state: 16777215, + }); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.COLOR, + external_id: 'tasmota:tasmota-device-topic:Channel', + selector: 'tasmota-tasmota-device-topic-channel', + name: 'Color', + read_only: false, + has_feedback: true, + min: 0, + max: 16777215, + last_value: 16777215, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/colorChannel-5.json b/server/test/services/tasmota/lib/device-creation/colorChannel-5.json new file mode 100644 index 0000000000..071381fbc8 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorChannel-5.json @@ -0,0 +1,50 @@ +{ + "STATUS": { + "Status": { + "Module": 13, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:02:53" + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "Channel": [100, 100, 100, 100, 100], + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/colorChannel-5.test.js b/server/test/services/tasmota/lib/device-creation/colorChannel-5.test.js new file mode 100644 index 0000000000..5b41096d78 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorChannel-5.test.js @@ -0,0 +1,133 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./colorChannel-5.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with COLOR Channel #RRGGBBCWWW feature', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.COLOR, + external_id: 'tasmota:tasmota-device-topic:Channel', + selector: 'tasmota-tasmota-device-topic-channel', + name: 'Color', + read_only: false, + has_feedback: true, + min: 0, + max: 16777215, + last_value: 16777215, + }, + ], + }, + }); + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:Channel', + state: 16777215, + }); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.COLOR, + external_id: 'tasmota:tasmota-device-topic:Channel', + selector: 'tasmota-tasmota-device-topic-channel', + name: 'Color', + read_only: false, + has_feedback: true, + min: 0, + max: 16777215, + last_value: 16777215, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/colorScheme.json b/server/test/services/tasmota/lib/device-creation/colorScheme.json new file mode 100644 index 0000000000..fbd1953dd0 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorScheme.json @@ -0,0 +1,50 @@ +{ + "STATUS": { + "Status": { + "Module": 13, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:02:53" + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "Scheme": 2, + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/colorScheme.test.js b/server/test/services/tasmota/lib/device-creation/colorScheme.test.js new file mode 100644 index 0000000000..03fd56cee9 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorScheme.test.js @@ -0,0 +1,133 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./colorScheme.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with COLOR Scheme/mode feature', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.EFFECT_MODE, + external_id: 'tasmota:tasmota-device-topic:Scheme', + selector: 'tasmota-tasmota-device-topic-scheme', + name: 'Effect mode', + read_only: false, + has_feedback: true, + min: 0, + max: 4, + last_value: 2, + }, + ], + }, + }); + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:Scheme', + state: 2, + }); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.EFFECT_MODE, + external_id: 'tasmota:tasmota-device-topic:Scheme', + selector: 'tasmota-tasmota-device-topic-scheme', + name: 'Effect mode', + read_only: false, + has_feedback: true, + min: 0, + max: 4, + last_value: 2, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/colorSpeed.json b/server/test/services/tasmota/lib/device-creation/colorSpeed.json new file mode 100644 index 0000000000..77e308b8dc --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorSpeed.json @@ -0,0 +1,50 @@ +{ + "STATUS": { + "Status": { + "Module": 13, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:02:53" + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "Speed": 2, + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/colorSpeed.test.js b/server/test/services/tasmota/lib/device-creation/colorSpeed.test.js new file mode 100644 index 0000000000..854d9dabd9 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorSpeed.test.js @@ -0,0 +1,133 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./colorSpeed.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with COLOR Speed feature', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.EFFECT_SPEED, + external_id: 'tasmota:tasmota-device-topic:Speed', + selector: 'tasmota-tasmota-device-topic-speed', + name: 'Effect speed', + read_only: false, + has_feedback: true, + min: 1, + max: 40, + last_value: 2, + }, + ], + }, + }); + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:Speed', + state: 2, + }); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.EFFECT_SPEED, + external_id: 'tasmota:tasmota-device-topic:Speed', + selector: 'tasmota-tasmota-device-topic-speed', + name: 'Effect speed', + read_only: false, + has_feedback: true, + min: 1, + max: 40, + last_value: 2, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/colorTemperature.json b/server/test/services/tasmota/lib/device-creation/colorTemperature.json new file mode 100644 index 0000000000..b9ea25a5cb --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorTemperature.json @@ -0,0 +1,50 @@ +{ + "STATUS": { + "Status": { + "Module": 13, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:02:53" + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "CT": 317, + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/colorTemperature.test.js b/server/test/services/tasmota/lib/device-creation/colorTemperature.test.js new file mode 100644 index 0000000000..06ef235f57 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/colorTemperature.test.js @@ -0,0 +1,133 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./colorTemperature.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with COLOR TEMP feature', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.TEMPERATURE, + external_id: 'tasmota:tasmota-device-topic:CT', + selector: 'tasmota-tasmota-device-topic-ct', + name: 'Color temperature', + read_only: false, + has_feedback: true, + min: 123, + max: 500, + last_value: 317, + }, + ], + }, + }); + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:CT', + state: 317, + }); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.TEMPERATURE, + external_id: 'tasmota:tasmota-device-topic:CT', + selector: 'tasmota-tasmota-device-topic-ct', + name: 'Color temperature', + read_only: false, + has_feedback: true, + min: 123, + max: 500, + last_value: 317, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/counter-feature.test.js b/server/test/services/tasmota/lib/device-creation/counter-feature.test.js new file mode 100644 index 0000000000..69ec8a372c --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/counter-feature.test.js @@ -0,0 +1,121 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./counter.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with COUNTER features', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 18, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 18, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 18, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.COUNTER_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.INTEGER, + external_id: 'tasmota:tasmota-device-topic:C1', + selector: 'tasmota-tasmota-device-topic-c1', + name: 'Counter 1', + read_only: true, + has_feedback: false, + min: 0, + max: 10000, + last_value: 57, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:C1', + state: 57, + }); + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + assert.notCalled(mqttService.device.publish); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/counter.json b/server/test/services/tasmota/lib/device-creation/counter.json new file mode 100644 index 0000000000..4d9e709cb3 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/counter.json @@ -0,0 +1,43 @@ +{ + "STATUS": { + "Status": { + "ButtonRetain": 0, + "ButtonTopic": "0", + "FriendlyName": ["Tasmota"], + "LedMask": "FFFF", + "LedState": 1, + "Module": 18, + "Power": 0, + "PowerOnState": 1, + "PowerRetain": 0, + "SaveData": 1, + "SaveState": 1, + "SensorRetain": 0, + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "SwitchRetain": 0, + "SwitchTopic": "0", + "Topic": "tasmota" + } + }, + "STATUS11": { + "StatusSTS": { + "MqttCount": 1, + "Sleep": 50, + "SleepMode": "Dynamic", + "Wifi": { + "AP": 1, + "BSSId": "EC:BE:DD:85:1F:E0", + "Channel": 1, + "LinkCount": 1, + "SSId": "ALEX-NETWORK" + } + } + }, + "STATUS8": { + "StatusSNS": { + "COUNTER": { + "C1": 57 + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/dimmer_light.json b/server/test/services/tasmota/lib/device-creation/dimmer_light.json new file mode 100644 index 0000000000..f6dcad2fb8 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/dimmer_light.json @@ -0,0 +1,50 @@ +{ + "STATUS": { + "Status": { + "Module": 9, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:02:53" + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "Dimmer": 100, + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/dimmer_light.test.js b/server/test/services/tasmota/lib/device-creation/dimmer_light.test.js new file mode 100644 index 0000000000..f005e6c9be --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/dimmer_light.test.js @@ -0,0 +1,133 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./dimmer_light.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with light Brightness/Dimmer feature', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 9, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 9, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.BRIGHTNESS, + external_id: 'tasmota:tasmota-device-topic:Dimmer', + selector: 'tasmota-tasmota-device-topic-dimmer', + name: 'Brightness', + read_only: false, + has_feedback: true, + min: 0, + max: 100, + last_value: 100, + }, + ], + }, + }); + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:Dimmer', + state: 100, + }); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 9, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.BRIGHTNESS, + external_id: 'tasmota:tasmota-device-topic:Dimmer', + selector: 'tasmota-tasmota-device-topic-dimmer', + name: 'Brightness', + read_only: false, + has_feedback: true, + min: 0, + max: 100, + last_value: 100, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/dimmer_switch.json b/server/test/services/tasmota/lib/device-creation/dimmer_switch.json new file mode 100644 index 0000000000..8463283fd4 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/dimmer_switch.json @@ -0,0 +1,50 @@ +{ + "STATUS": { + "Status": { + "Module": 1, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:02:53" + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "Dimmer": 14, + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/dimmer_switch.test.js b/server/test/services/tasmota/lib/device-creation/dimmer_switch.test.js new file mode 100644 index 0000000000..f315e75e5d --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/dimmer_switch.test.js @@ -0,0 +1,133 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./dimmer_switch.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with switch Dimmer feature', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 1, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 1, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.DIMMER, + external_id: 'tasmota:tasmota-device-topic:Dimmer', + selector: 'tasmota-tasmota-device-topic-dimmer', + name: 'Dimmer', + read_only: false, + has_feedback: true, + min: 0, + max: 100, + last_value: 14, + }, + ], + }, + }); + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:Dimmer', + state: 14, + }); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 1, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.DIMMER, + external_id: 'tasmota:tasmota-device-topic:Dimmer', + selector: 'tasmota-tasmota-device-topic-dimmer', + name: 'Dimmer', + read_only: false, + has_feedback: true, + min: 0, + max: 100, + last_value: 14, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/energy-feature.test.js b/server/test/services/tasmota/lib/device-creation/energy-feature.test.js new file mode 100644 index 0000000000..9fbe509732 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/energy-feature.test.js @@ -0,0 +1,157 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./energy.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with ENERGY features', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 13, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.POWER, + external_id: 'tasmota:tasmota-device-topic:ENERGY:Power', + selector: 'tasmota-tasmota-device-topic-energy-power', + name: 'Power', + read_only: true, + has_feedback: false, + min: 0, + max: 10000, + last_value: 57, + unit: DEVICE_FEATURE_UNITS.KILOWATT, + }, + { + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.VOLTAGE, + external_id: 'tasmota:tasmota-device-topic:ENERGY:Voltage', + selector: 'tasmota-tasmota-device-topic-energy-voltage', + name: 'Voltage', + read_only: true, + has_feedback: false, + min: 0, + max: 10000, + last_value: 12, + unit: DEVICE_FEATURE_UNITS.VOLT, + }, + { + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.ENERGY, + external_id: 'tasmota:tasmota-device-topic:ENERGY:Current', + selector: 'tasmota-tasmota-device-topic-energy-current', + name: 'Energy', + read_only: true, + has_feedback: false, + min: 0, + max: 10000, + last_value: 20, + unit: DEVICE_FEATURE_UNITS.AMPERE, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:ENERGY:Power', + state: 57, + }); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:ENERGY:Voltage', + state: 12, + }); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:ENERGY:Current', + state: 20, + }); + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + assert.notCalled(mqttService.device.publish); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/energy.json b/server/test/services/tasmota/lib/device-creation/energy.json new file mode 100644 index 0000000000..4cd8e70730 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/energy.json @@ -0,0 +1,61 @@ +{ + "STATUS": { + "Status": { + "Module": 13, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:14:14", + "ENERGY": { + "TotalStartTime": "2019-01-19T12:29:20", + "Total": 6.911, + "Yesterday": 0.0, + "Today": 0.001, + "Power": 57, + "ApparentPower": 0, + "ReactivePower": 0, + "Factor": 0.0, + "Voltage": 12000, + "Current": 20.0 + } + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/power_light_off.json b/server/test/services/tasmota/lib/device-creation/power_light_off.json new file mode 100644 index 0000000000..7a83ba3dfb --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/power_light_off.json @@ -0,0 +1,50 @@ +{ + "STATUS": { + "Status": { + "Module": 9, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:02:53" + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "POWER": "OFF", + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/power_light_off.test.js b/server/test/services/tasmota/lib/device-creation/power_light_off.test.js new file mode 100644 index 0000000000..3369ddfe3e --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/power_light_off.test.js @@ -0,0 +1,133 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./power_light_off.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with light POWER OFF feature', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 9, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 9, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.BINARY, + external_id: 'tasmota:tasmota-device-topic:POWER', + selector: 'tasmota-tasmota-device-topic-power', + name: 'Switch', + read_only: false, + has_feedback: true, + min: 0, + max: 1, + last_value: 0, + }, + ], + }, + }); + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:POWER', + state: 0, + }); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 9, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.BINARY, + external_id: 'tasmota:tasmota-device-topic:POWER', + selector: 'tasmota-tasmota-device-topic-power', + name: 'Switch', + read_only: false, + has_feedback: true, + min: 0, + max: 1, + last_value: 0, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/power_light_on.json b/server/test/services/tasmota/lib/device-creation/power_light_on.json new file mode 100644 index 0000000000..261eb4c9ef --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/power_light_on.json @@ -0,0 +1,50 @@ +{ + "STATUS": { + "Status": { + "Module": 9, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:02:53" + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "POWER": "ON", + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/power_light_on.test.js b/server/test/services/tasmota/lib/device-creation/power_light_on.test.js new file mode 100644 index 0000000000..835ac162d1 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/power_light_on.test.js @@ -0,0 +1,133 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./power_light_on.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with light POWER ON feature', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 9, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 9, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.BINARY, + external_id: 'tasmota:tasmota-device-topic:POWER', + selector: 'tasmota-tasmota-device-topic-power', + name: 'Switch', + read_only: false, + has_feedback: true, + min: 0, + max: 1, + last_value: 1, + }, + ], + }, + }); + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:POWER', + state: 1, + }); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 9, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.BINARY, + external_id: 'tasmota:tasmota-device-topic:POWER', + selector: 'tasmota-tasmota-device-topic-power', + name: 'Switch', + read_only: false, + has_feedback: true, + min: 0, + max: 1, + last_value: 1, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/power_switch_off.json b/server/test/services/tasmota/lib/device-creation/power_switch_off.json new file mode 100644 index 0000000000..48638e74ae --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/power_switch_off.json @@ -0,0 +1,50 @@ +{ + "STATUS": { + "Status": { + "Module": 1, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:02:53" + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "POWER": "OFF", + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/power_switch_off.test.js b/server/test/services/tasmota/lib/device-creation/power_switch_off.test.js new file mode 100644 index 0000000000..cf7eeaf84e --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/power_switch_off.test.js @@ -0,0 +1,134 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./power_switch_off.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with switch POWER OFF feature', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 1, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 1, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + external_id: 'tasmota:tasmota-device-topic:POWER', + selector: 'tasmota-tasmota-device-topic-power', + name: 'Switch', + read_only: false, + has_feedback: true, + min: 0, + max: 1, + last_value: 0, + }, + ], + }, + }); + + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:POWER', + state: 0, + }); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 1, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + external_id: 'tasmota:tasmota-device-topic:POWER', + selector: 'tasmota-tasmota-device-topic-power', + name: 'Switch', + read_only: false, + has_feedback: true, + min: 0, + max: 1, + last_value: 0, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/power_switch_on.json b/server/test/services/tasmota/lib/device-creation/power_switch_on.json new file mode 100644 index 0000000000..3ed195681c --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/power_switch_on.json @@ -0,0 +1,50 @@ +{ + "STATUS": { + "Status": { + "Module": 1, + "FriendlyName": ["Tasmota"], + "Topic": "tasmota", + "ButtonTopic": "0", + "Power": 0, + "PowerOnState": 3, + "LedState": 1, + "LedMask": "FFFF", + "SaveData": 1, + "SaveState": 1, + "SwitchTopic": "0", + "SwitchMode": [0, 0, 0, 0, 0, 0, 0, 0], + "ButtonRetain": 0, + "SwitchRetain": 0, + "SensorRetain": 0, + "PowerRetain": 0 + } + }, + "STATUS8": { + "StatusSNS": { + "Time": "2020-01-10T22:02:53" + } + }, + "STATUS11": { + "StatusSTS": { + "Time": "2020-01-10T22:02:53", + "Uptime": "0T00:00:08", + "UptimeSec": 8, + "Heap": 29, + "SleepMode": "Dynamic", + "Sleep": 50, + "LoadAvg": 26, + "MqttCount": 1, + "POWER": "ON", + "Wifi": { + "AP": 1, + "SSId": "MY-NETWORK", + "BSSId": "XX:XX:XX:XX:XX:XX", + "Channel": 1, + "RSSI": 76, + "Signal": -62, + "LinkCount": 1, + "Downtime": "0T00:00:06" + } + } + } +} diff --git a/server/test/services/tasmota/lib/device-creation/power_switch_on.test.js b/server/test/services/tasmota/lib/device-creation/power_switch_on.test.js new file mode 100644 index 0000000000..3c1c282747 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/power_switch_on.test.js @@ -0,0 +1,134 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./power_switch_on.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with switch POWER ON feature', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 1, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 1, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + external_id: 'tasmota:tasmota-device-topic:POWER', + selector: 'tasmota-tasmota-device-topic-power', + name: 'Switch', + read_only: false, + has_feedback: true, + min: 0, + max: 1, + last_value: 1, + }, + ], + }, + }); + + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:POWER', + state: 1, + }); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 1, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + external_id: 'tasmota:tasmota-device-topic:POWER', + selector: 'tasmota-tasmota-device-topic-power', + name: 'Switch', + read_only: false, + has_feedback: true, + min: 0, + max: 1, + last_value: 1, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/temperture_humdity_AM2301.test.js b/server/test/services/tasmota/lib/device-creation/temperture_humdity_AM2301.test.js new file mode 100644 index 0000000000..f6d937c5b4 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/temperture_humdity_AM2301.test.js @@ -0,0 +1,141 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./AM2301.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with AM2301 temp/humidity features', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 18, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 18, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 18, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.HUMIDITY_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.DECIMAL, + unit: DEVICE_FEATURE_UNITS.PERCENT, + external_id: 'tasmota:tasmota-device-topic:AM2301:Humidity', + selector: 'tasmota-tasmota-device-topic-am2301-humidity', + name: 'Humidity', + read_only: true, + has_feedback: false, + min: 0, + max: 100, + last_value: 65, + }, + { + category: DEVICE_FEATURE_CATEGORIES.TEMPERATURE_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.DECIMAL, + unit: DEVICE_FEATURE_UNITS.CELSIUS, + external_id: 'tasmota:tasmota-device-topic:AM2301:Temperature', + selector: 'tasmota-tasmota-device-topic-am2301-temperature', + name: 'Temperature', + read_only: true, + has_feedback: false, + min: -100, + max: 200, + last_value: 23, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:AM2301:Temperature', + state: 23, + }); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:AM2301:Humidity', + state: 65, + }); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-creation/temperture_humdity_DHT11.test.js b/server/test/services/tasmota/lib/device-creation/temperture_humdity_DHT11.test.js new file mode 100644 index 0000000000..0cf66dc131 --- /dev/null +++ b/server/test/services/tasmota/lib/device-creation/temperture_humdity_DHT11.test.js @@ -0,0 +1,141 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, + EVENTS, + WEBSOCKET_MESSAGE_TYPES, +} = require('../../../../../utils/constants'); + +const messages = require('./DHT11.json'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = { + event: { + emit: fake.returns(null), + }, + stateManager: { + get: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - create device with DHT11 temp/humidity features', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + tasmotaHandler.mqttService = mqttService; + sinon.reset(); + }); + + it('decode STATUS message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS', JSON.stringify(messages.STATUS)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 18, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '11'); + }); + + it('decode STATUS11 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS11', JSON.stringify(messages.STATUS11)); + + expect(tasmotaHandler.mqttDevices).to.deep.eq({}); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({ + 'tasmota-device-topic': { + name: 'Tasmota', + model: 18, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [], + }, + }); + + assert.notCalled(gladys.event.emit); + assert.notCalled(gladys.stateManager.get); + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/STATUS', '8'); + }); + + it('decode STATUS8 message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/STATUS8', JSON.stringify(messages.STATUS8)); + + const expectedDevice = { + name: 'Tasmota', + model: 18, + external_id: 'tasmota:tasmota-device-topic', + selector: 'tasmota-tasmota-device-topic', + service_id: serviceId, + should_poll: false, + features: [ + { + category: DEVICE_FEATURE_CATEGORIES.HUMIDITY_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.DECIMAL, + unit: DEVICE_FEATURE_UNITS.PERCENT, + external_id: 'tasmota:tasmota-device-topic:DHT11:Humidity', + selector: 'tasmota-tasmota-device-topic-dht11-humidity', + name: 'Humidity', + read_only: true, + has_feedback: false, + min: 0, + max: 100, + last_value: 65, + }, + { + category: DEVICE_FEATURE_CATEGORIES.TEMPERATURE_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.DECIMAL, + unit: DEVICE_FEATURE_UNITS.CELSIUS, + external_id: 'tasmota:tasmota-device-topic:DHT11:Temperature', + selector: 'tasmota-tasmota-device-topic-dht11-temperature', + name: 'Temperature', + read_only: true, + has_feedback: false, + min: -100, + max: 200, + last_value: 23, + }, + ], + }; + expect(tasmotaHandler.mqttDevices).to.deep.eq({ + 'tasmota-device-topic': expectedDevice, + }); + expect(tasmotaHandler.pendingMqttDevices).to.deep.eq({}); + + assert.notCalled(mqttService.device.publish); + assert.calledWith(gladys.stateManager.get, 'deviceByExternalId', 'tasmota:tasmota-device-topic'); + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:DHT11:Temperature', + state: 23, + }); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tasmota:tasmota-device-topic:DHT11:Humidity', + state: 65, + }); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TASMOTA.NEW_DEVICE, + payload: expectedDevice, + }); + }); +}); diff --git a/server/test/services/tasmota/lib/device-new_state/new_state-colorChannel.test.js b/server/test/services/tasmota/lib/device-new_state/new_state-colorChannel.test.js new file mode 100644 index 0000000000..bc2e85a4d9 --- /dev/null +++ b/server/test/services/tasmota/lib/device-new_state/new_state-colorChannel.test.js @@ -0,0 +1,31 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { EVENTS } = require('../../../../../utils/constants'); + +const gladys = { + event: { + emit: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - handle new state Color Channel', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + it('decode RESULT message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/RESULT', '{ "Channel": [100,100,100] }'); + + const expectedEvent = { + device_feature_external_id: `tasmota:tasmota-device-topic:Channel`, + state: 16777215, + }; + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); + }); +}); diff --git a/server/test/services/tasmota/lib/device-new_state/new_state-colorScheme.test.js b/server/test/services/tasmota/lib/device-new_state/new_state-colorScheme.test.js new file mode 100644 index 0000000000..a468598875 --- /dev/null +++ b/server/test/services/tasmota/lib/device-new_state/new_state-colorScheme.test.js @@ -0,0 +1,31 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { EVENTS } = require('../../../../../utils/constants'); + +const gladys = { + event: { + emit: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - handle new state Color Scheme', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + it('decode RESULT message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/RESULT', '{ "Scheme": 1 }'); + + const expectedEvent = { + device_feature_external_id: `tasmota:tasmota-device-topic:Scheme`, + state: 1, + }; + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); + }); +}); diff --git a/server/test/services/tasmota/lib/device-new_state/new_state-colorSpeed.test.js b/server/test/services/tasmota/lib/device-new_state/new_state-colorSpeed.test.js new file mode 100644 index 0000000000..094cf91ca5 --- /dev/null +++ b/server/test/services/tasmota/lib/device-new_state/new_state-colorSpeed.test.js @@ -0,0 +1,31 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { EVENTS } = require('../../../../../utils/constants'); + +const gladys = { + event: { + emit: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - handle new state Color speed', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + it('decode RESULT message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/RESULT', '{ "Speed": 11 }'); + + const expectedEvent = { + device_feature_external_id: `tasmota:tasmota-device-topic:Speed`, + state: 11, + }; + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); + }); +}); diff --git a/server/test/services/tasmota/lib/device-new_state/new_state-colorTemperature.test.js b/server/test/services/tasmota/lib/device-new_state/new_state-colorTemperature.test.js new file mode 100644 index 0000000000..3c9b231351 --- /dev/null +++ b/server/test/services/tasmota/lib/device-new_state/new_state-colorTemperature.test.js @@ -0,0 +1,31 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { EVENTS } = require('../../../../../utils/constants'); + +const gladys = { + event: { + emit: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - handle new state CT', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + it('decode RESULT CT message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/RESULT', '{ "CT": 125 }'); + + const expectedEvent = { + device_feature_external_id: `tasmota:tasmota-device-topic:CT`, + state: 125, + }; + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); + }); +}); diff --git a/server/test/services/tasmota/lib/device-new_state/new_state-dimmer.test.js b/server/test/services/tasmota/lib/device-new_state/new_state-dimmer.test.js new file mode 100644 index 0000000000..f9faf4c43b --- /dev/null +++ b/server/test/services/tasmota/lib/device-new_state/new_state-dimmer.test.js @@ -0,0 +1,31 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { EVENTS } = require('../../../../../utils/constants'); + +const gladys = { + event: { + emit: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - handle new state Dimmer', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + it('decode RESULT Dimmer message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/RESULT', '{ "Dimmer": 125 }'); + + const expectedEvent = { + device_feature_external_id: `tasmota:tasmota-device-topic:Dimmer`, + state: 125, + }; + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); + }); +}); diff --git a/server/test/services/tasmota/lib/device-new_state/new_state-energy.test.js b/server/test/services/tasmota/lib/device-new_state/new_state-energy.test.js new file mode 100644 index 0000000000..e3d867f699 --- /dev/null +++ b/server/test/services/tasmota/lib/device-new_state/new_state-energy.test.js @@ -0,0 +1,53 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { EVENTS } = require('../../../../../utils/constants'); + +const gladys = { + event: { + emit: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - handle new state ENERGY', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + it('decode SENSOR Voltage message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/SENSOR', JSON.stringify({ ENERGY: { Voltage: 125 } })); + + const expectedEvent = { + device_feature_external_id: `tasmota:tasmota-device-topic:ENERGY:Voltage`, + state: 0.125, + }; + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); + }); + + it('decode SENSOR Current message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/SENSOR', JSON.stringify({ ENERGY: { Current: 125 } })); + + const expectedEvent = { + device_feature_external_id: `tasmota:tasmota-device-topic:ENERGY:Current`, + state: 125, + }; + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); + }); + + it('decode SENSOR Power message', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/SENSOR', JSON.stringify({ ENERGY: { Power: 125 } })); + + const expectedEvent = { + device_feature_external_id: `tasmota:tasmota-device-topic:ENERGY:Power`, + state: 125, + }; + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); + }); +}); diff --git a/server/test/services/tasmota/lib/device-new_state/new_state-power.test.js b/server/test/services/tasmota/lib/device-new_state/new_state-power.test.js new file mode 100644 index 0000000000..5938605cfb --- /dev/null +++ b/server/test/services/tasmota/lib/device-new_state/new_state-power.test.js @@ -0,0 +1,64 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); +const { EVENTS } = require('../../../../../utils/constants'); + +const gladys = { + event: { + emit: fake.returns(null), + }, +}; +const serviceId = 'service-uuid-random'; + +describe('TasmotaHandler - handle new state POWER', () => { + const tasmotaHandler = new TasmotaHandler(gladys, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + it('decode RESULT message => ON', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/RESULT', '{"POWER":"ON"}'); + + const expectedEvent = { + device_feature_external_id: `tasmota:tasmota-device-topic:POWER`, + state: 1, + }; + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); + }); + + it('decode RESULT message => OFF', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/RESULT', '{"POWER":"OFF"}'); + + const expectedEvent = { + device_feature_external_id: `tasmota:tasmota-device-topic:POWER`, + state: 0, + }; + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); + }); + + it('decode POWER RESULT message => ON', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/RESULT', '{"POWER5":"ON"}'); + + const expectedEvent = { + device_feature_external_id: `tasmota:tasmota-device-topic:POWER5`, + state: 1, + }; + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); + }); + + it('decode POWER RESULT message => OFF', () => { + tasmotaHandler.handleMqttMessage('stat/tasmota-device-topic/RESULT', '{"POWER5":"OFF"}'); + + const expectedEvent = { + device_feature_external_id: `tasmota:tasmota-device-topic:POWER5`, + state: 0, + }; + + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, expectedEvent); + }); +}); diff --git a/server/test/services/tasmota/lib/device-setValue/setValue-colorChannel.test.js b/server/test/services/tasmota/lib/device-setValue/setValue-colorChannel.test.js new file mode 100644 index 0000000000..bc3be170d4 --- /dev/null +++ b/server/test/services/tasmota/lib/device-setValue/setValue-colorChannel.test.js @@ -0,0 +1,32 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); + +const mqttService = { + device: { + publish: fake.resolves(null), + }, +}; +const gladys = {}; + +describe('TasmotaHandler - setValue - Color', () => { + const tasmotaHandler = new TasmotaHandler(gladys, 'service-uuid-random'); + tasmotaHandler.mqttService = mqttService; + + beforeEach(() => { + sinon.reset(); + }); + + it('Set number value', () => { + const device = {}; + const feature = { + external_id: 'tasmota:tasmota-device-topic:Channel', + }; + const value = 1243700; + + tasmotaHandler.setValue(device, feature, value); + + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/Channel', '18,250,52'); + }); +}); diff --git a/server/test/services/tasmota/lib/device-setValue/setValue-colorScheme.test.js b/server/test/services/tasmota/lib/device-setValue/setValue-colorScheme.test.js new file mode 100644 index 0000000000..4138576b1f --- /dev/null +++ b/server/test/services/tasmota/lib/device-setValue/setValue-colorScheme.test.js @@ -0,0 +1,32 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); + +const mqttService = { + device: { + publish: fake.resolves(null), + }, +}; +const gladys = {}; + +describe('TasmotaHandler - setValue - Color Scheme', () => { + const tasmotaHandler = new TasmotaHandler(gladys, 'service-uuid-random'); + tasmotaHandler.mqttService = mqttService; + + beforeEach(() => { + sinon.reset(); + }); + + it('Set number value', () => { + const device = {}; + const feature = { + external_id: 'tasmota:tasmota-device-topic:Scheme', + }; + const value = 3; + + tasmotaHandler.setValue(device, feature, value); + + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/Scheme', '3'); + }); +}); diff --git a/server/test/services/tasmota/lib/device-setValue/setValue-colorSpeed.test.js b/server/test/services/tasmota/lib/device-setValue/setValue-colorSpeed.test.js new file mode 100644 index 0000000000..9c3e10f095 --- /dev/null +++ b/server/test/services/tasmota/lib/device-setValue/setValue-colorSpeed.test.js @@ -0,0 +1,32 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); + +const mqttService = { + device: { + publish: fake.resolves(null), + }, +}; +const gladys = {}; + +describe('TasmotaHandler - setValue - Color Speed', () => { + const tasmotaHandler = new TasmotaHandler(gladys, 'service-uuid-random'); + tasmotaHandler.mqttService = mqttService; + + beforeEach(() => { + sinon.reset(); + }); + + it('Set number value', () => { + const device = {}; + const feature = { + external_id: 'tasmota:tasmota-device-topic:Speed', + }; + const value = 37; + + tasmotaHandler.setValue(device, feature, value); + + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/Speed', '37'); + }); +}); diff --git a/server/test/services/tasmota/lib/device-setValue/setValue-colorTemperature.test.js b/server/test/services/tasmota/lib/device-setValue/setValue-colorTemperature.test.js new file mode 100644 index 0000000000..75cdafcf11 --- /dev/null +++ b/server/test/services/tasmota/lib/device-setValue/setValue-colorTemperature.test.js @@ -0,0 +1,32 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); + +const mqttService = { + device: { + publish: fake.resolves(null), + }, +}; +const gladys = {}; + +describe('TasmotaHandler - setValue - Color Temperature (CT)', () => { + const tasmotaHandler = new TasmotaHandler(gladys, 'service-uuid-random'); + tasmotaHandler.mqttService = mqttService; + + beforeEach(() => { + sinon.reset(); + }); + + it('Set number value', () => { + const device = {}; + const feature = { + external_id: 'tasmota:tasmota-device-topic:CT', + }; + const value = 278; + + tasmotaHandler.setValue(device, feature, value); + + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/CT', '278'); + }); +}); diff --git a/server/test/services/tasmota/lib/device-setValue/setValue-dimmer.test.js b/server/test/services/tasmota/lib/device-setValue/setValue-dimmer.test.js new file mode 100644 index 0000000000..c67ed52df1 --- /dev/null +++ b/server/test/services/tasmota/lib/device-setValue/setValue-dimmer.test.js @@ -0,0 +1,32 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); + +const mqttService = { + device: { + publish: fake.resolves(null), + }, +}; +const gladys = {}; + +describe('TasmotaHandler - setValue - Dimmer', () => { + const tasmotaHandler = new TasmotaHandler(gladys, 'service-uuid-random'); + tasmotaHandler.mqttService = mqttService; + + beforeEach(() => { + sinon.reset(); + }); + + it('Set number value', () => { + const device = {}; + const feature = { + external_id: 'tasmota:tasmota-device-topic:Dimmer', + }; + const value = 72; + + tasmotaHandler.setValue(device, feature, value); + + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/Dimmer', '72'); + }); +}); diff --git a/server/test/services/tasmota/lib/device-setValue/setValue-energy.test.js b/server/test/services/tasmota/lib/device-setValue/setValue-energy.test.js new file mode 100644 index 0000000000..9e99b31f78 --- /dev/null +++ b/server/test/services/tasmota/lib/device-setValue/setValue-energy.test.js @@ -0,0 +1,65 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); + +const mqttService = { + device: { + publish: fake.resolves(null), + }, +}; +const gladys = {}; + +describe('TasmotaHandler - setValue - FAIL', () => { + const tasmotaHandler = new TasmotaHandler(gladys, 'service-uuid-random'); + tasmotaHandler.mqttService = mqttService; + + beforeEach(() => { + sinon.reset(); + }); + + it('Set value voltage', () => { + const device = {}; + const feature = { + external_id: 'tasmota:tasmota-device-topic:ENERGY:Voltage', + }; + const value = 1; + + try { + tasmotaHandler.setValue(device, feature, value); + assert.fail('Should fail'); + } catch (e) { + assert.notCalled(mqttService.device.publish); + } + }); + + it('Set value current', () => { + const device = {}; + const feature = { + external_id: 'tasmota:tasmota-device-topic:ENERGY:Current', + }; + const value = 1; + + try { + tasmotaHandler.setValue(device, feature, value); + assert.fail('Should fail'); + } catch (e) { + assert.notCalled(mqttService.device.publish); + } + }); + + it('Set value power', () => { + const device = {}; + const feature = { + external_id: 'tasmota:tasmota-device-topic:ENERGY:Power', + }; + const value = 1; + + try { + tasmotaHandler.setValue(device, feature, value); + assert.fail('Should fail'); + } catch (e) { + assert.notCalled(mqttService.device.publish); + } + }); +}); diff --git a/server/test/services/tasmota/lib/device-setValue/setValue-power-x.test.js b/server/test/services/tasmota/lib/device-setValue/setValue-power-x.test.js new file mode 100644 index 0000000000..3b8b3933ff --- /dev/null +++ b/server/test/services/tasmota/lib/device-setValue/setValue-power-x.test.js @@ -0,0 +1,44 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); + +const mqttService = { + device: { + publish: fake.resolves(null), + }, +}; +const gladys = {}; + +describe('TasmotaHandler - setValue - POWER', () => { + const tasmotaHandler = new TasmotaHandler(gladys, 'service-uuid-random'); + tasmotaHandler.mqttService = mqttService; + + beforeEach(() => { + sinon.reset(); + }); + + it('Set power ON', () => { + const device = {}; + const feature = { + external_id: 'tasmota:tasmota-device-topic:POWER123', + }; + const value = 1; + + tasmotaHandler.setValue(device, feature, value); + + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/POWER123', 'ON'); + }); + + it('Set power OFF', () => { + const device = {}; + const feature = { + external_id: 'tasmota:tasmota-device-topic:POWER123', + }; + const value = 0; + + tasmotaHandler.setValue(device, feature, value); + + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/POWER123', 'OFF'); + }); +}); diff --git a/server/test/services/tasmota/lib/device-setValue/setValue-power.test.js b/server/test/services/tasmota/lib/device-setValue/setValue-power.test.js new file mode 100644 index 0000000000..f3cd56ab55 --- /dev/null +++ b/server/test/services/tasmota/lib/device-setValue/setValue-power.test.js @@ -0,0 +1,44 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const TasmotaHandler = require('../../../../../services/tasmota/lib'); + +const mqttService = { + device: { + publish: fake.resolves(null), + }, +}; +const gladys = {}; + +describe('TasmotaHandler - setValue - POWER', () => { + const tasmotaHandler = new TasmotaHandler(gladys, 'service-uuid-random'); + tasmotaHandler.mqttService = mqttService; + + beforeEach(() => { + sinon.reset(); + }); + + it('Set power ON', () => { + const device = {}; + const feature = { + external_id: 'tasmota:tasmota-device-topic:POWER', + }; + const value = 1; + + tasmotaHandler.setValue(device, feature, value); + + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/POWER', 'ON'); + }); + + it('Set power OFF', () => { + const device = {}; + const feature = { + external_id: 'tasmota:tasmota-device-topic:POWER', + }; + const value = 0; + + tasmotaHandler.setValue(device, feature, value); + + assert.calledWith(mqttService.device.publish, 'cmnd/tasmota-device-topic/POWER', 'OFF'); + }); +}); diff --git a/server/test/services/tasmota/lib/device-setValue/setValue.test.js b/server/test/services/tasmota/lib/device-setValue/setValue.test.js new file mode 100644 index 0000000000..d09be8992d --- /dev/null +++ b/server/test/services/tasmota/lib/device-setValue/setValue.test.js @@ -0,0 +1,55 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; +const { expect } = require('chai'); +const TasmotaHandler = require('../../../../../services/tasmota/lib'); + +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; +const gladys = {}; + +describe('TasmotaHandler - setValue', () => { + const tasmotaHandler = new TasmotaHandler(gladys, 'service-uuid-random'); + tasmotaHandler.mqttService = mqttService; + + beforeEach(() => { + sinon.reset(); + }); + + it('publish through invalid topic', () => { + const device = undefined; + const feature = { + external_id: 'deviceInvalidTopic', + }; + const value = 1; + + try { + tasmotaHandler.setValue(device, feature, value); + assert.fail('Should ends on error'); + } catch (e) { + assert.notCalled(mqttService.device.publish); + expect(e.message).to.eq( + 'Tasmota device external_id is invalid: "deviceInvalidTopic" should starts with "tasmota:"', + ); + } + }); + + it('publish through null topic', () => { + const device = undefined; + const feature = { + external_id: 'tasmota:', + }; + const value = 1; + + try { + tasmotaHandler.setValue(device, feature, value); + assert.fail('Should ends on error'); + } catch (e) { + assert.notCalled(mqttService.device.publish); + expect(e.message).to.eq('Tasmota device external_id is invalid: "tasmota:" have no MQTT topic'); + } + }); +}); diff --git a/server/test/services/sonoff/lib/disconnect.test.js b/server/test/services/tasmota/lib/disconnect.test.js similarity index 59% rename from server/test/services/sonoff/lib/disconnect.test.js rename to server/test/services/tasmota/lib/disconnect.test.js index 29081120f8..4beeb564d1 100644 --- a/server/test/services/sonoff/lib/disconnect.test.js +++ b/server/test/services/tasmota/lib/disconnect.test.js @@ -1,7 +1,7 @@ const sinon = require('sinon'); const { fake, assert } = sinon; -const SonoffHandler = require('../../../../services/sonoff/lib'); +const TasmotaHandler = require('../../../../services/tasmota/lib'); const mqttService = { device: { @@ -9,16 +9,16 @@ const mqttService = { }, }; -describe('SonoffHandler - disconnect', () => { - const sonoffHandler = new SonoffHandler({}, 'service-uuid-random'); - sonoffHandler.mqttService = mqttService; +describe('TasmotaHandler - disconnect', () => { + const tasmotaHandler = new TasmotaHandler({}, 'service-uuid-random'); + tasmotaHandler.mqttService = mqttService; beforeEach(() => { sinon.reset(); }); it('disconnect with unsubscription', () => { - sonoffHandler.disconnect(); + tasmotaHandler.disconnect(); assert.calledWith(mqttService.device.unsubscribe, 'stat/+/+'); assert.calledWith(mqttService.device.unsubscribe, 'tele/+/+'); diff --git a/server/test/services/tasmota/lib/getDiscoveredDevices.test.js b/server/test/services/tasmota/lib/getDiscoveredDevices.test.js new file mode 100644 index 0000000000..5eea381547 --- /dev/null +++ b/server/test/services/tasmota/lib/getDiscoveredDevices.test.js @@ -0,0 +1,127 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const TasmotaHandler = require('../../../../services/tasmota/lib'); + +const existingDevice = { + external_id: 'alreadyExists', + name: 'alreadyExists', + model: 'sonoff-basic', + features: [ + { + name: 'feature 1', + type: 'type 1', + category: 'category 1', + external_id: 'external_id:1', + }, + { + name: 'feature 2', + type: 'type 2', + category: 'category 2', + external_id: 'external_id:2', + }, + ], +}; + +const gladys = { + stateManager: { + get: (key, externalId) => { + if (externalId === 'alreadyExists') { + return existingDevice; + } + return undefined; + }, + }, +}; + +describe('TasmotaHandler - getDiscoveredDevices', () => { + const tasmotaHandler = new TasmotaHandler(gladys, 'service-uuid-random'); + sinon.spy(tasmotaHandler, 'handleMqttMessage'); + + beforeEach(() => { + sinon.reset(); + tasmotaHandler.mqttDevices = {}; + }); + + it('nothing discovered', () => { + const result = tasmotaHandler.getDiscoveredDevices(); + expect(result).to.be.lengthOf(0); + }); + + it('discovered already in Gladys', () => { + tasmotaHandler.mqttDevices.alreadyExists = { + external_id: 'alreadyExists', + model: 'sonoff-basic', + }; + const result = tasmotaHandler.getDiscoveredDevices(); + expect(result).to.be.lengthOf(1); + expect(result).deep.eq([existingDevice]); + }); + + it('discovered already in Gladys, but updated (basic to pow)', () => { + tasmotaHandler.mqttDevices.alreadyExists = { + external_id: 'alreadyExists', + model: 'sonoff-pow', + features: [ + { + name: 'feature 1', + type: 'type 1', + category: 'category 1', + external_id: 'external_id:1', + }, + { + name: 'feature 2 bis', + type: 'type 2', + category: 'category 2', + external_id: 'external_id:2', + }, + { + name: 'feature 3', + type: 'type 3', + category: 'category 3', + external_id: 'external_id:3', + }, + ], + }; + const result = tasmotaHandler.getDiscoveredDevices(); + expect(result).to.be.lengthOf(1); + + const expectedDevice = { + external_id: 'alreadyExists', + model: 'sonoff-pow', + name: 'alreadyExists', + features: [ + { + name: 'feature 1', + type: 'type 1', + category: 'category 1', + external_id: 'external_id:1', + }, + { + name: 'feature 2 bis', + type: 'type 2', + category: 'category 2', + external_id: 'external_id:2', + }, + { + name: 'feature 3', + type: 'type 3', + category: 'category 3', + external_id: 'external_id:3', + }, + ], + }; + expectedDevice.updatable = true; + expectedDevice.name = 'alreadyExists'; + expect(result).deep.eq([expectedDevice]); + }); + + it('discovered not already in Gladys', () => { + tasmotaHandler.mqttDevices.notAlreadyExists = { + external_id: 'notAlreadyExists', + }; + const result = tasmotaHandler.getDiscoveredDevices(); + expect(result).to.be.lengthOf(1); + expect(result).deep.eq([tasmotaHandler.mqttDevices.notAlreadyExists]); + }); +}); diff --git a/server/test/services/tasmota/lib/handleMqttMessage.test.js b/server/test/services/tasmota/lib/handleMqttMessage.test.js new file mode 100644 index 0000000000..04e6e85816 --- /dev/null +++ b/server/test/services/tasmota/lib/handleMqttMessage.test.js @@ -0,0 +1,48 @@ +const sinon = require('sinon'); + +const { assert, fake } = sinon; +const TasmotaHandler = require('../../../../services/tasmota/lib'); + +const gladys = { + variable: { + getValue: fake.resolves('result'), + }, + event: { + emit: fake.returns(null), + }, +}; +const mqttService = { + device: { + publish: fake.returns(null), + }, +}; + +describe('Mqtt handle message', () => { + const tasmotaHandler = new TasmotaHandler(gladys, 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'); + tasmotaHandler.mqttService = mqttService; + + beforeEach(async () => { + sinon.reset(); + }); + + it('should change TASMOTA sensor state not handled (SENSOR topic)', () => { + tasmotaHandler.handleMqttMessage('tele/my_device/SENSOR', JSON.stringify({ HELLO: 'with_value ?' })); + + assert.notCalled(mqttService.device.publish); + assert.notCalled(gladys.event.emit); + }); + + it('should ask for TASMOTA status (LWT topic)', () => { + tasmotaHandler.handleMqttMessage('stat/my_device/LWT', 'anything'); + + assert.calledWith(mqttService.device.publish, 'cmnd/my_device/status'); + assert.notCalled(gladys.event.emit); + }); + + it('should do nothing on unkown TASMOTA topic', () => { + tasmotaHandler.handleMqttMessage('stat/my_device/UNKOWN', '{ "POWER": "ON"}'); + + assert.notCalled(mqttService.device.publish); + assert.notCalled(gladys.event.emit); + }); +}); diff --git a/server/test/services/tasmota/tasmota.mock.test.js b/server/test/services/tasmota/tasmota.mock.test.js new file mode 100644 index 0000000000..dc7e7bf152 --- /dev/null +++ b/server/test/services/tasmota/tasmota.mock.test.js @@ -0,0 +1,14 @@ +const { fake } = require('sinon'); + +const TasmotaHandler = function TasmotaHandler(gladys, serviceId) { + this.gladys = gladys; + this.serviceId = serviceId; + this.mqttService = null; + this.mqttDevices = {}; +}; + +TasmotaHandler.prototype.connect = fake.returns(null); +TasmotaHandler.prototype.disconnect = fake.returns(null); +TasmotaHandler.prototype.handleMqttMessage = fake.returns(null); + +module.exports = TasmotaHandler; diff --git a/server/utils/constants.js b/server/utils/constants.js index 8c29a65210..4eddb3d19a 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -228,6 +228,7 @@ const DEVICE_FEATURE_CATEGORIES = { OPENING_SENSOR: 'opening-sensor', HUMIDITY_SENSOR: 'humidity-sensor', VIBRATION_SENSOR: 'vibration-sensor', + COUNTER_SENSOR: 'counter-sensor', LEAK_SENSOR: 'leak-sensor', CAMERA: 'camera', SWITCH: 'switch', @@ -247,6 +248,8 @@ const DEVICE_FEATURE_TYPES = { COLOR: 'color', TEMPERATURE: 'temperature', POWER: 'power', + EFFECT_MODE: 'effect-mode', + EFFECT_SPEED: 'effect-speed', }, SENSOR: { DECIMAL: 'decimal', @@ -301,6 +304,8 @@ const DEVICE_FEATURE_UNITS = { LUX: 'lux', KILOWATT: 'kilowatt', KILOWATT_HOUR: 'kilowatt-hour', + AMPERE: 'ampere', + VOLT: 'volt', }; const ACTIONS_STATUS = { @@ -364,6 +369,9 @@ const WEBSOCKET_MESSAGE_TYPES = { XIAOMI: { NEW_DEVICE: 'xiaomi.new-device', }, + TASMOTA: { + NEW_DEVICE: 'tasmota.new-device', + }, }; const DASHBOARD_TYPE = {