diff --git a/front/src/assets/integrations/cover/sunspec.png b/front/src/assets/integrations/cover/sunspec.png new file mode 100644 index 0000000000..fa9c6a6695 Binary files /dev/null and b/front/src/assets/integrations/cover/sunspec.png differ diff --git a/front/src/components/app.jsx b/front/src/components/app.jsx index a63c43cccd..b6140e5e26 100644 --- a/front/src/components/app.jsx +++ b/front/src/components/app.jsx @@ -133,6 +133,11 @@ import EweLinkSetupPage from '../routes/integration/all/ewelink/setup-page'; // OpenAI integration import OpenAIPage from '../routes/integration/all/openai/index'; +// SunSpec integration +import SunSpecDevicePage from '../routes/integration/all/sunspec/device-page'; +import SunSpecDiscoverPage from '../routes/integration/all/sunspec/discover-page'; +import SunSpecSettingsPage from '../routes/integration/all/sunspec/settings-page'; + // Tuya integration import TuyaPage from '../routes/integration/all/tuya/device-page'; import TuyaEditPage from '../routes/integration/all/tuya/edit-page'; @@ -295,6 +300,10 @@ const AppRouter = connect( + + + + diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index ea119531f1..1da34c67ab 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -1418,7 +1418,64 @@ "activateOpenAiChat": "Activate ChatGPT in Gladys chat", "rateLimit": "As ChatGPT API is not free, this integration is limited to 1000 requests per month.", "notOnGladysPlus": "As ChatGPT API is not free, this integration is only available to Gladys Plus users.", - "subscribeToGladysPlus": "Click here to subscribe to Gladys Plus." + "subscribeToGladysPlus": "Click here to subscribe to Gladys Plus.", + "licenseShouldBeActive": "This integration is only available to users with an active license (at least one payment). For trial users, please contact us on the forum or email." + }, + "sunspec": { + "title": "SunSpec", + "description": "Manage you SunSpec device through you local network.", + "deviceTab": "Devices", + "discoverTab": "Discover", + "settingsTab": "Settings", + "device": { + "title": "SunSpec Devices", + "search": "Search devices", + "noDevices": "No SunSpec devices added yet.", + "manufacturerLabel": "Manufacturer", + "ipAddressLabel": "IP address", + "macAddressLabel": "MAC address", + "originalNameLabel": "Hostname" + }, + "discover": { + "title": "SunSpec Discover", + "noDevices": "No SunSpec devices found.", + "scanButton": "Scan network", + "deviceUpdateButton": "New devices available", + "hideExistingDevices": "Hide already added devices", + "alreadyCreatedButton": "Already created", + "updateButton": "Update", + "discoveringError": "An error occurred, check scanner configuration, or check logs." + }, + "settings": { + "title": "SunSpec settings", + "errorLabel": "An error occurred while loading configuration.", + "noConfigLabel": "Configuration not loaded, please retry.", + "sunspecUrl": "SunSpec URL", + "sunspecUrlPlaceholder": "Ex: [sunspec-address]:[port]", + "bdpvActiveLabel": "Activate BDPV Synchronisation", + "userLabel": "Nom d'utilisateur", + "userPlaceholder": "Entrez le nom d'utilisateur BDPV", + "apiKeyLabel": "Clé API", + "apiKeyPlaceholder": "Entrez la clé API", + "fronius": { + "description": "Activate Modbus TCP on Fronius inverters", + "documentation": "Fronius Documentation" + }, + "sma": { + "description": "Activate Modbus TCP on SAM inverters", + "documentation": "SMA Documentation" + }, + "ipMasksLabel": "CIDR to scan", + "masksDescription": "CIDR (Classless Inter-Domain Routing) is a notation to describe a range of IP addresses in a sub-network. You can write it as an IP address, followed by a network mask number, both separated by a slash (example: 192.168.1.1/10). There is many CIDR calculators on the Internet which will help you to compute your own.", + "networkInterfaceDescription": "Masks defined by your computer on your networks, with network interfaces, can't be removed.", + "maskTableHeader": "CIDR", + "nameTableHeader": "Name", + "statusTableHeader": "Active", + "maskTablePlaceholder": "e.g. 192.168.1.1/20", + "nameTablePlaceholder": "Enter CIDR name", + "deleteButtonTooltip": "Delete", + "maskFormatError": "Invalid CIDR format" + } } }, "editScene": { @@ -2666,6 +2723,13 @@ "shortCategoryName": "Air Quality", "aqi": "Air Quality Index" }, + "pv": { + "shortCategoryName": "Photovoltaic", + "voltage": "Voltage", + "current": "Current", + "power": "Power", + "energy": "Energy" + }, "text": { "shortCategoryName": "Text", "text": "Text" diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 567b38fb1f..efa7bba211 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -1419,7 +1419,64 @@ "activateOpenAiChat": "Activer ChatGPT dans le chat Gladys", "rateLimit": "L'API ChatGPT étant payante, cette intégration est actuellement limitée à 1 000 requêtes par mois et par compte.", "notOnGladysPlus": "L'API ChatGPT étant payante, cette intégration est proposée via Gladys Plus uniquement.", - "subscribeToGladysPlus": "Cliquez-ici pour souscrire à Gladys Plus." + "subscribeToGladysPlus": "Cliquez-ici pour souscrire à Gladys Plus.", + "licenseShouldBeActive": "Cette intégration n'est disponible qu'aux utilisateurs Gladys Plus dont l'abonnement est actuellement actif avec au moins un paiement. Pour les utilisateurs en périodes d'essai, merci de me contacter sur le forum ou par email !" + }, + "sunspec": { + "title": "SunSpec Manager", + "description": "Gérez les appareils SunSpec sur votre réseau local.", + "deviceTab": "Appareils", + "discoverTab": "Découverte", + "settingsTab": "Configuration", + "device": { + "title": "Appareils SunSpec", + "search": "Chercher un appareil", + "noDevices": "Aucun appareil SunSpec n'a encore été ajouté.", + "manufacturerLabel": "Construteur", + "ipAddressLabel": "Adresse IP", + "macAddressLabel": "Adresse MAC", + "originalNameLabel": "Nom sur le réseau" + }, + "discover": { + "title": "Découverte SunSpec", + "noDevices": "Aucun appareil SunSpec n'a encore été trouvé.", + "scanButton": "Recherche sur le réseau", + "deviceUpdateButton": "Nouveaux appareils disponibles", + "hideExistingDevices": "Cacher les appareils déjà ajoutés", + "alreadyCreatedButton": "Déjà créé", + "updateButton": "Mettre à jour", + "discoveringError": "Une erreur est survenue lors de la recherche, vérifiez la configuration du scanner, sinon consultez les logs." + }, + "settings": { + "title": "Configuration SunSpec", + "errorLabel": "Une erreur est survenue lors du chargement de la configuration.", + "noConfigLabel": "La configuration n'est pas chargée, merci de réessayer.", + "sunspecUrl": "SunSpec URL", + "sunspecUrlPlaceholder": "Ex: [sunspec-address]:[port]", + "bdpvActiveLabel": "Activer la synchronisation BDPV", + "userLabel": "Nom d'utilisateur", + "userPlaceholder": "Entrez le nom d'utilisateur BDPV", + "apiKeyLabel": "Clé API", + "apiKeyPlaceholder": "Entrez la clé API", + "fronius": { + "description": "Activez l'interface Modbus TCP sur les onduleurs Fronius", + "documentation": "Documentation Fronius" + }, + "sma": { + "description": "Activez l'interface Modbus TCP sur les onduleurs SMA", + "documentation": "Documentation SMA" + }, + "ipMasksLabel": "CIDR à scanner", + "masksDescription": "La notation CIDR (Classless Inter-Domain Routing) permet de définir un masque de sous-réseau, c'est à dire une plage d'IP. Sa syntaxe correspond à une adresse IP du réseau, suivi d'un nombre, le masque, séparés par une barre oblique (exemple : 192.168.1.1/10). Il existe de multiples calculateurs d'adresse CIDR sur Internet qui vous aideront à composer celles qui vont correspondent le mieux.", + "networkInterfaceDescription": "Les masques enregistrés par votre ordinateur, selon les interfaces réseaux, ne peuvent pas être supprimés.", + "maskTableHeader": "CIDR", + "nameTableHeader": "Nom", + "statusTableHeader": "Actif", + "maskTablePlaceholder": "e.g. 192.168.1.1/20", + "nameTablePlaceholder": "Entrer le nom du CIDR", + "deleteButtonTooltip": "Supprimer", + "maskFormatError": "Format CIDR invalide" + } } }, "editScene": { @@ -2667,6 +2724,13 @@ "shortCategoryName": "Qualité de l'air", "aqi": "Indice de qualité de l'air" }, + "pv": { + "shortCategoryName": "Photovoltaique", + "voltage": "Tension", + "current": "Intensité", + "power": "Puissance", + "energy": "Energie" + }, "text": { "shortCategoryName": "Texte", "text": "Texte" diff --git a/front/src/config/integrations/devices.json b/front/src/config/integrations/devices.json index 95915d48de..3e780c0a04 100644 --- a/front/src/config/integrations/devices.json +++ b/front/src/config/integrations/devices.json @@ -59,6 +59,10 @@ "link": "lan-manager", "img": "/assets/integrations/cover/lan-manager.jpg" }, + { + "key": "sunspec", + "img": "/assets/integrations/cover/sunspec.png" + }, { "key": "tuya", "link": "tuya", diff --git a/front/src/routes/integration/all/sunspec/EmptyState.jsx b/front/src/routes/integration/all/sunspec/EmptyState.jsx new file mode 100644 index 0000000000..7390b5e0e0 --- /dev/null +++ b/front/src/routes/integration/all/sunspec/EmptyState.jsx @@ -0,0 +1,14 @@ +import { Text } from 'preact-i18n'; +import cx from 'classnames'; + +import style from './style.css'; + +const EmptyState = ({ id }) => ( +
+
+ +
+
+); + +export default EmptyState; diff --git a/front/src/routes/integration/all/sunspec/SunSpecPage.js b/front/src/routes/integration/all/sunspec/SunSpecPage.js new file mode 100644 index 0000000000..d33c9f34b6 --- /dev/null +++ b/front/src/routes/integration/all/sunspec/SunSpecPage.js @@ -0,0 +1,60 @@ +import { Text } from 'preact-i18n'; +import { Link } from 'preact-router/match'; + +const SunSpecPage = ({ children }) => ( +
+
+
+
+
+
+

+ +

+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ +
{children}
+
+
+
+
+
+); + +export default SunSpecPage; diff --git a/front/src/routes/integration/all/sunspec/device-page/SunSpecDevice.jsx b/front/src/routes/integration/all/sunspec/device-page/SunSpecDevice.jsx new file mode 100644 index 0000000000..10b6d2610a --- /dev/null +++ b/front/src/routes/integration/all/sunspec/device-page/SunSpecDevice.jsx @@ -0,0 +1,117 @@ +import { Text, Localizer, MarkupText } from 'preact-i18n'; +import { Component } from 'preact'; +import cx from 'classnames'; +import get from 'get-value'; + +import { RequestStatus } from '../../../../../utils/consts'; +import DeviceFeatures from '../../../../../components/device/view/DeviceFeatures'; + +class SunSpecDevice extends Component { + saveDevice = async () => { + this.setState({ loading: true }); + try { + await this.props.saveDevice(this.props.deviceIndex); + } catch (e) { + this.setState({ error: RequestStatus.Error }); + } + this.setState({ loading: false }); + }; + deleteDevice = async () => { + this.setState({ loading: true, tooMuchStatesError: false, statesNumber: undefined }); + try { + await this.props.deleteDevice(this.props.device, this.props.deviceIndex); + } catch (e) { + const status = get(e, 'response.status'); + const dataMessage = get(e, 'response.data.message'); + if (status === 400 && dataMessage && dataMessage.includes('Too much states')) { + const statesNumber = new Intl.NumberFormat().format(dataMessage.split(' ')[0]); + this.setState({ tooMuchStatesError: true, statesNumber }); + } else { + this.setState({ error: RequestStatus.Error }); + } + } + this.setState({ loading: false }); + }; + updateName = e => { + this.props.updateDeviceProperty(this.props.deviceIndex, 'name', e.target.value); + }; + updateRoom = e => { + this.props.updateDeviceProperty(this.props.deviceIndex, 'room_id', e.target.value); + }; + + render({ device, houses }, { loading, tooMuchStatesError, statesNumber }) { + return ( +
+
+
{device.name}
+
+
+
+
+ {tooMuchStatesError && ( +
+ +
+ )} +
+ + + } + /> + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ ); + } +} + +export default SunSpecDevice; diff --git a/front/src/routes/integration/all/sunspec/device-page/SunSpecDeviceTab.jsx b/front/src/routes/integration/all/sunspec/device-page/SunSpecDeviceTab.jsx new file mode 100644 index 0000000000..20a952136e --- /dev/null +++ b/front/src/routes/integration/all/sunspec/device-page/SunSpecDeviceTab.jsx @@ -0,0 +1,59 @@ +import { Text, Localizer } from 'preact-i18n'; +import cx from 'classnames'; + +import { RequestStatus } from '../../../../../utils/consts'; +import SunSpecDevice from './SunSpecDevice'; +import EmptyState from '../EmptyState'; +import style from '../style.css'; +import CardFilter from '../../../../../components/layout/CardFilter'; + +const SunSpecDeviceTab = ({ children, getSunSpecDevicesStatus, sunspecDevices, ...props }) => ( +
+
+

+ +

+
+ + } + /> + +
+
+
+
+
+
+
+ {sunspecDevices && + sunspecDevices.map((sunspecDevice, index) => ( + + ))} + {(!sunspecDevices || sunspecDevices.length === 0) && ( + + )} +
+
+
+
+
+); + +export default SunSpecDeviceTab; diff --git a/front/src/routes/integration/all/sunspec/device-page/actions.js b/front/src/routes/integration/all/sunspec/device-page/actions.js new file mode 100644 index 0000000000..6f5a18e04c --- /dev/null +++ b/front/src/routes/integration/all/sunspec/device-page/actions.js @@ -0,0 +1,88 @@ +import { RequestStatus } from '../../../../../utils/consts'; +import update from 'immutability-helper'; +import createActionsHouse from '../../../../../actions/house'; +import debounce from 'debounce'; + +function createActions(store) { + const houseActions = createActionsHouse(store); + const actions = { + async getSunSpecDevices(state) { + store.setState({ + getSunSpecDevicesStatus: RequestStatus.Getting + }); + try { + const options = { + service: 'sunspec', + order_dir: state.getSunSpecDeviceOrderDir || 'asc' + }; + if (state.sunspecDeviceSearch && state.sunspecDeviceSearch.length) { + options.search = state.sunspecDeviceSearch; + } + const sunspecDevices = await state.httpClient.get('/api/v1/service/sunspec/device', options); + store.setState({ + sunspecDevices, + getSunSpecDevicesStatus: RequestStatus.Success + }); + } catch (e) { + store.setState({ + getSunSpecDevicesStatus: RequestStatus.Error + }); + } + }, + async saveDevice(state, deviceIndex) { + const device = state.sunspecDevices[deviceIndex]; + + try { + const savedDevice = await state.httpClient.post('/api/v1/device', device); + const newState = update(state, { + sunspecDevices: { + [deviceIndex]: { + $set: savedDevice + } + } + }); + store.setState(newState); + } catch (e) { + console.error(e); + throw e; + } + }, + updateDeviceProperty(state, index, property, value) { + const newState = update(state, { + sunspecDevices: { + [index]: { + [property]: { + $set: value + } + } + } + }); + store.setState(newState); + }, + async deleteDevice(state, device, index) { + await state.httpClient.delete(`/api/v1/device/${device.selector}`); + const newState = update(state, { + sunspecDevices: { + $splice: [[index, 1]] + } + }); + store.setState(newState); + }, + async search(state, e) { + store.setState({ + sunspecDeviceSearch: e.target.value + }); + await actions.getSunSpecDevices(store.getState(), 20, 0); + }, + async changeOrderDir(state, e) { + store.setState({ + getSunSpecDeviceOrderDir: e.target.value + }); + await actions.getSunSpecDevices(store.getState(), 20, 0); + } + }; + actions.debouncedSearch = debounce(actions.search, 200); + return Object.assign({}, houseActions, actions); +} + +export default createActions; diff --git a/front/src/routes/integration/all/sunspec/device-page/index.js b/front/src/routes/integration/all/sunspec/device-page/index.js new file mode 100644 index 0000000000..e7ad17c90f --- /dev/null +++ b/front/src/routes/integration/all/sunspec/device-page/index.js @@ -0,0 +1,25 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import actions from './actions'; +import SunSpecPage from '../SunSpecPage'; +import SunSpecDeviceTab from './SunSpecDeviceTab'; + +class SunSpecDevicePage extends Component { + componentWillMount() { + this.props.getSunSpecDevices(); + this.props.getHouses(); + } + + render(props, {}) { + return ( + + + + ); + } +} + +export default connect( + 'session,httpClient,user,sunspecDevices,houses,getSunSpecDevicesStatus', + actions +)(SunSpecDevicePage); diff --git a/front/src/routes/integration/all/sunspec/discover-page/SunSpecDiscoverDevice.jsx b/front/src/routes/integration/all/sunspec/discover-page/SunSpecDiscoverDevice.jsx new file mode 100644 index 0000000000..6fbd954ffc --- /dev/null +++ b/front/src/routes/integration/all/sunspec/discover-page/SunSpecDiscoverDevice.jsx @@ -0,0 +1,83 @@ +import { Text, Localizer } from 'preact-i18n'; +import { Component } from 'preact'; +import cx from 'classnames'; + +import { RequestStatus } from '../../../../../utils/consts'; +import DeviceFeatures from '../../../../../components/device/view/DeviceFeatures'; + +class SunSpecDiscoverDevice extends Component { + saveDevice = async () => { + this.setState({ loading: true }); + try { + await this.props.saveDevice(this.props.deviceIndex); + } catch (e) { + this.setState({ error: RequestStatus.Error }); + } + this.setState({ loading: false }); + }; + updateName = e => { + this.props.updateDeviceProperty(this.props.deviceIndex, 'name', e.target.value); + }; + + render({ device }, { loading }) { + return ( +
+
+
{device.name}
+
+
+
+
+
+ + + } + /> + +
+
+ + +
+
+ {device.created_at && ( + + )} + + {!device.created_at && ( + + )} +
+
+
+
+
+
+ ); + } +} + +export default SunSpecDiscoverDevice; diff --git a/front/src/routes/integration/all/sunspec/discover-page/SunSpecDiscoverTab.jsx b/front/src/routes/integration/all/sunspec/discover-page/SunSpecDiscoverTab.jsx new file mode 100644 index 0000000000..6cbd74f181 --- /dev/null +++ b/front/src/routes/integration/all/sunspec/discover-page/SunSpecDiscoverTab.jsx @@ -0,0 +1,90 @@ +import { Text } from 'preact-i18n'; +import cx from 'classnames'; + +import { RequestStatus } from '../../../../../utils/consts'; +import SunSpecDiscoverDevice from './SunSpecDiscoverDevice'; +import EmptyState from '../EmptyState'; +import style from '../style.css'; + +const SunSpecDiscoverTab = ({ + children, + sunspecGetDiscoveredDevicesStatus, + sunspecDiscoveredDevices = [], + sunspecDiscoverUpdate = true, + sunspecStatus = {}, + getDiscoveredDevices, + scan, + ...props +}) => { + const { scanning } = sunspecStatus; + const displayLoader = scanning && sunspecDiscoveredDevices.length === 0; + + return ( +
+
+

+ +

+
+ {sunspecDiscoverUpdate && ( + + )} + +
+
+ {scanning && sunspecDiscoveredDevices.length > 0 && ( +
+
+
+ )} +
+ {sunspecGetDiscoveredDevicesStatus === RequestStatus.Error && ( +
+ +
+ )} + +
+
+
+
+ {sunspecDiscoveredDevices.map((sunspecDevice, index) => ( + + ))} + {sunspecDiscoveredDevices.length === 0 && } +
+
+
+
+
+ ); +}; + +export default SunSpecDiscoverTab; diff --git a/front/src/routes/integration/all/sunspec/discover-page/actions.js b/front/src/routes/integration/all/sunspec/discover-page/actions.js new file mode 100644 index 0000000000..8efaf7509a --- /dev/null +++ b/front/src/routes/integration/all/sunspec/discover-page/actions.js @@ -0,0 +1,124 @@ +import update from 'immutability-helper'; + +import { RequestStatus } from '../../../../../utils/consts'; +import createActionsHouse from '../../../../../actions/house'; + +const createActions = store => { + const houseActions = createActionsHouse(store); + const actions = { + async getDiscoveredDevices(state = {}) { + store.setState({ + sunspecGetDiscoveredDevicesStatus: RequestStatus.Getting + }); + try { + const { filterExisting = true } = state; + const sunspecDiscoveredDevices = await state.httpClient.get('/api/v1/service/sunspec/discover', { + filterExisting + }); + store.setState({ + sunspecDiscoveredDevices, + sunspecGetDiscoveredDevicesStatus: RequestStatus.Success, + sunspecDiscoverUpdate: false + }); + } catch (e) { + console.error(e); + store.setState({ + sunspecGetDiscoveredDevicesStatus: RequestStatus.Error + }); + } + }, + async toggleFilterOnExisting(state = {}) { + const { filterExisting = true } = state; + store.setState({ + filterExisting: !filterExisting + }); + + await actions.getDiscoveredDevices(store.getState()); + }, + async getSunSpecStatus(state) { + let sunspecStatus = { scanning: false }; + try { + sunspecStatus = await state.httpClient.get('/api/v1/service/sunspec/status'); + } finally { + store.setState({ + sunspecStatus + }); + } + }, + async scan(state) { + try { + store.setState({ + sunspecStatus: { scanning: true } + }); + await state.httpClient.post('/api/v1/service/sunspec/discover'); + } catch (e) { + console.error(e); + store.setState({ + sunspecStatus: { scanning: false } + }); + } + }, + async handleStatus(state = {}, sunspecStatus) { + store.setState({ + sunspecStatus + }); + + const { sunspecDiscoveredDevices = [], sunspecGetDiscoveredDevicesStatus } = state; + + // when scan stops + if (!sunspecStatus.scanning) { + if (sunspecStatus.success === false) { + store.setState({ + sunspecGetDiscoveredDevicesStatus: RequestStatus.Error + }); + } else if (sunspecDiscoveredDevices.length === 0) { + // if no device are currently fetched, refresh list + await actions.getDiscoveredDevices(store.getState()); + } else if (sunspecStatus.deviceChanged) { + // or display refresh button + store.setState({ + sunspecDiscoverUpdate: true, + sunspecGetDiscoveredDevicesStatus: RequestStatus.Success + }); + } + } else if (sunspecGetDiscoveredDevicesStatus !== RequestStatus.Getting) { + store.setState({ + sunspecGetDiscoveredDevicesStatus: RequestStatus.Getting + }); + } + }, + async saveDevice(state = {}, deviceIndex) { + const device = state.sunspecDiscoveredDevices[deviceIndex]; + + try { + const savedDevice = await state.httpClient.post('/api/v1/device', device); + const newState = update(state, { + sunspecDiscoveredDevices: { + [deviceIndex]: { + $set: savedDevice + } + } + }); + store.setState(newState); + } catch (e) { + console.error(e); + throw e; + } + }, + updateDeviceProperty(state, index, property, value) { + const newState = update(state, { + sunspecDiscoveredDevices: { + [index]: { + [property]: { + $set: value + } + } + } + }); + store.setState(newState); + } + }; + return Object.assign({}, actions, houseActions); +}; + +export default createActions; diff --git a/front/src/routes/integration/all/sunspec/discover-page/index.js b/front/src/routes/integration/all/sunspec/discover-page/index.js new file mode 100644 index 0000000000..0dc9167ae6 --- /dev/null +++ b/front/src/routes/integration/all/sunspec/discover-page/index.js @@ -0,0 +1,33 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import actions from './actions'; +import SunSpecPage from '../SunSpecPage'; +import SunSpecDiscoverTab from './SunSpecDiscoverTab'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../../../server/utils/constants'; + +class SunSpecDiscoverPage extends Component { + componentWillMount() { + this.props.getSunSpecStatus(); + this.props.getDiscoveredDevices(); + this.props.getHouses(); + + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.SUNSPEC.SCANNING, this.props.handleStatus); + } + + componentWillUnmount() { + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.SUNSPEC.SCANNING, this.props.handleStatus); + } + + render(props, {}) { + return ( + + + + ); + } +} + +export default connect( + 'session,httpClient,houses,sunspecDiscoveredDevices,sunspecGetDiscoveredDevicesStatus,sunspecDiscoverUpdate,sunspecStatus,filterExisting', + actions +)(SunSpecDiscoverPage); diff --git a/front/src/routes/integration/all/sunspec/settings-page/SunSpecIPLine.jsx b/front/src/routes/integration/all/sunspec/settings-page/SunSpecIPLine.jsx new file mode 100644 index 0000000000..fbcb435667 --- /dev/null +++ b/front/src/routes/integration/all/sunspec/settings-page/SunSpecIPLine.jsx @@ -0,0 +1,57 @@ +import { Component } from 'preact'; +import { Localizer, Text } from 'preact-i18n'; + +class SunSpecIPLine extends Component { + toggleStatus = () => { + const { ipMask, maskIndex } = this.props; + this.props.updateMaskConfig(maskIndex, { ...ipMask, enabled: !ipMask.enabled }); + }; + + deleteMask = () => { + const { maskIndex } = this.props; + this.props.deleteMaskConfig(maskIndex); + }; + + render({ ipMask, disabled }) { + const { networkInterface, enabled, mask, name } = ipMask; + const editable = !networkInterface; + return ( + + {mask} + {name} + + + + + + + + + + ); + } +} + +export default SunSpecIPLine; diff --git a/front/src/routes/integration/all/sunspec/settings-page/SunSpecIPRange.jsx b/front/src/routes/integration/all/sunspec/settings-page/SunSpecIPRange.jsx new file mode 100644 index 0000000000..0cc80f5860 --- /dev/null +++ b/front/src/routes/integration/all/sunspec/settings-page/SunSpecIPRange.jsx @@ -0,0 +1,123 @@ +import { Component } from 'preact'; +import { Localizer, Text } from 'preact-i18n'; +import cx from 'classnames'; + +import SunSpecIPLine from './SunSpecIPLine'; + +const IPV4_CIDR_REGEX = /(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}\/(3[0-2]|[12]?[0-9])/; + +class SunSpecIPRange extends Component { + changeNewMaskName = e => { + const { value } = e.target; + this.setState({ name: value }); + }; + + changeNewMask = e => { + const { value } = e.target; + const valid = IPV4_CIDR_REGEX.test(value); + this.setState({ mask: value, valid }); + }; + + addMask = () => { + const { name, mask } = this.state; + this.props.addMaskConfig({ name, mask, enabled: true }); + this.setState({ name: '', mask: '', valid: false }); + }; + + render({ ipMasks = {}, disabled, updateMaskConfig, deleteMaskConfig }, { name = '', mask = '', valid }) { + const invalidMask = mask.length > 0 && !valid; + return ( +
+
+ +
+ +
+
+ + + + + + + + + + {ipMasks.map((mask, maskIndex) => ( + + ))} + + + + + + + + +
+ + + + + + +
+
+ + } + disabled={disabled} + onChange={this.changeNewMask} + value={mask} + /> + +
+ + + +
+
+ {invalidMask && ( +
+ +
+ )} +
+
+ + } + disabled={disabled} + onChange={this.changeNewMaskName} + value={name} + /> + +
+
+ + +
+
+
+
+ ); + } +} + +export default SunSpecIPRange; diff --git a/front/src/routes/integration/all/sunspec/settings-page/SunSpecSettingsTab.jsx b/front/src/routes/integration/all/sunspec/settings-page/SunSpecSettingsTab.jsx new file mode 100644 index 0000000000..2c80badf88 --- /dev/null +++ b/front/src/routes/integration/all/sunspec/settings-page/SunSpecSettingsTab.jsx @@ -0,0 +1,255 @@ +import { Component } from 'preact'; +import { Text, MarkupText, Localizer } from 'preact-i18n'; +import { Link } from 'preact-router/match'; +import cx from 'classnames'; +import update from 'immutability-helper'; + +import SunSpecIPRange from './SunSpecIPRange'; + +class SunSpecSettingsTab extends Component { + loadConfiguration = async () => { + this.setState({ loading: true, error: null }); + try { + const config = await this.props.httpClient.get('/api/v1/service/sunspec/config'); + this.setState({ loading: false, config }); + } catch (e) { + console.error(e); + this.setState({ loading: false, error: e }); + } + }; + + updateConfiguration(state, configuration) { + this.setState(configuration); + } + + updateUrl = e => { + const updatedConfig = update(this.state.config, { + sunspecUrl: { + $set: e.target.value + } + }); + + this.setState({ config: updatedConfig, updated: true }); + }; + + toggleBdpvActive = () => { + const updatedConfig = update(this.state.config, { + bdpvActive: { + $set: !this.state.config.bdpvActive + } + }); + + this.setState({ config: updatedConfig, updated: true }); + }; + + updateUsername = e => { + const updatedConfig = update(this.state.config, { + bdpvUsername: { + $set: e.target.value + } + }); + + this.setState({ config: updatedConfig, updated: true }); + }; + + updateApiKey = e => { + const updatedConfig = update(this.state.config, { + bdpvApiKey: { + $set: e.target.value + } + }); + + this.setState({ config: updatedConfig, updated: true }); + }; + + showApiKey = () => { + this.setState({ showApiKey: true }); + setTimeout(() => this.setState({ showApiKey: false }), 5000); + }; + + updateMaskConfig = async (maskIndex, value) => { + const updatedConfig = update(this.state.config, { + ipMasks: { + [maskIndex]: { + $set: value + } + } + }); + + this.setState({ config: updatedConfig, updated: true }); + }; + + addMaskConfig = async value => { + const updatedConfig = update(this.state.config, { + ipMasks: { + $push: [value] + } + }); + + this.setState({ config: updatedConfig, updated: true }); + }; + + deleteMaskConfig = async maskIndex => { + const updatedConfig = update(this.state.config, { + ipMasks: { + $splice: [[maskIndex, 1]] + } + }); + + this.setState({ config: updatedConfig, updated: true }); + }; + + saveConfiguration = async () => { + this.setState({ error: null }); + try { + const config = await this.props.httpClient.post('/api/v1/service/sunspec/config', this.state.config); + this.setState({ config, updated: false }); + await this.props.httpClient.post('/api/v1/service/sunspec/disconnect'); + await this.props.httpClient.post('/api/v1/service/sunspec/connect'); + } catch (e) { + this.setState({ error: e }); + } + }; + + async componentWillMount() { + await this.loadConfiguration(); + } + + render({}, { config, showApiKey, loading, error, saving }) { + return ( +
+
+

+ +

+
+
+
+
+
+ {error && ( +
+ +
+ )} + + {!error && !config && ( +
+ +
+ )} + +

+ + + + +

+ +

+ + + + +

+ + {config && ( + + )} + + {config && ( +
+
+ + +
+
+ )} + + {config && config.bdpvActive && ( + <> +
+ + + } + value={config.bdpvUsername} + class="form-control" + onInput={this.updateUsername} + autoComplete="no" + /> + +
+
+ +
+ + } + value={config.bdpvApiKey} + class="form-control" + onInput={this.updateApiKey} + autoComplete="new-password" + /> + + + + +
+
+ + )} + +
+ +
+
+
+
+
+ ); + } +} + +export default SunSpecSettingsTab; diff --git a/front/src/routes/integration/all/sunspec/settings-page/index.js b/front/src/routes/integration/all/sunspec/settings-page/index.js new file mode 100644 index 0000000000..1da5733f74 --- /dev/null +++ b/front/src/routes/integration/all/sunspec/settings-page/index.js @@ -0,0 +1,17 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; + +import SunSpecPage from '../SunSpecPage'; +import SunSpecSettingsTab from './SunSpecSettingsTab'; + +class SunSpecSettingsPage extends Component { + render(props) { + return ( + + + + ); + } +} + +export default connect('user,httpClient')(SunSpecSettingsPage); diff --git a/front/src/routes/integration/all/sunspec/style.css b/front/src/routes/integration/all/sunspec/style.css new file mode 100644 index 0000000000..3b397dbfb0 --- /dev/null +++ b/front/src/routes/integration/all/sunspec/style.css @@ -0,0 +1,8 @@ +.emptyStateDivBox { + margin-top: 35px; + margin-bottom: 35px; +} + +.sunspecListBody { + min-height: 200px; +} diff --git a/front/src/utils/consts.js b/front/src/utils/consts.js index d344680196..217fee1af4 100644 --- a/front/src/utils/consts.js +++ b/front/src/utils/consts.js @@ -300,6 +300,12 @@ export const DeviceFeatureCategoriesIcon = { [DEVICE_FEATURE_CATEGORIES.AIRQUALITY_SENSOR]: { [DEVICE_FEATURE_TYPES.AIRQUALITY_SENSOR.AQI]: 'bar-chart-2' }, + [DEVICE_FEATURE_CATEGORIES.PV]: { + [DEVICE_FEATURE_TYPES.PV.POWER]: 'zap', + [DEVICE_FEATURE_TYPES.PV.ENERGY]: 'zap', + [DEVICE_FEATURE_TYPES.PV.CURRENT]: 'zap', + [DEVICE_FEATURE_TYPES.PV.VOLTAGE]: 'zap' + }, [DEVICE_FEATURE_CATEGORIES.TEXT]: { [DEVICE_FEATURE_TYPES.TEXT.TEXT]: 'type' }, diff --git a/server/lib/device/device.setupPoll.js b/server/lib/device/device.setupPoll.js index 409f0feae8..005edc2894 100644 --- a/server/lib/device/device.setupPoll.js +++ b/server/lib/device/device.setupPoll.js @@ -1,4 +1,4 @@ -const { DEVICE_POLL_FREQUENCIES } = require('../../utils/constants'); +const { DEVICE_POLL_FREQUENCIES_LIST } = require('../../utils/constants'); /** * @description Setup poll setInterval. @@ -6,16 +6,9 @@ const { DEVICE_POLL_FREQUENCIES } = require('../../utils/constants'); * setupPoll(); */ function setupPoll() { - // poll devices who need to be polled every minutes - setInterval(this.pollAll(DEVICE_POLL_FREQUENCIES.EVERY_MINUTES), DEVICE_POLL_FREQUENCIES.EVERY_MINUTES); - // poll devices who need to be polled every 30 seconds - setInterval(this.pollAll(DEVICE_POLL_FREQUENCIES.EVERY_30_SECONDS), DEVICE_POLL_FREQUENCIES.EVERY_30_SECONDS); - // poll devices who need to be polled every 10 seconds - setInterval(this.pollAll(DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS), DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS); - // poll devices who need to be polled every 2 seconds - setInterval(this.pollAll(DEVICE_POLL_FREQUENCIES.EVERY_2_SECONDS), DEVICE_POLL_FREQUENCIES.EVERY_2_SECONDS); - // poll devices who need to be polled every 1 seconds - setInterval(this.pollAll(DEVICE_POLL_FREQUENCIES.EVERY_SECONDS), DEVICE_POLL_FREQUENCIES.EVERY_SECONDS); + DEVICE_POLL_FREQUENCIES_LIST.forEach((pollFrequency) => { + setInterval(this.pollAll(pollFrequency), pollFrequency); + }); } module.exports = { diff --git a/server/services/index.js b/server/services/index.js index 83c929aee9..fdcb5aa2d9 100644 --- a/server/services/index.js +++ b/server/services/index.js @@ -19,6 +19,7 @@ module.exports['google-actions'] = require('./google-actions'); module.exports.homekit = require('./homekit'); module.exports.broadlink = require('./broadlink'); module.exports['lan-manager'] = require('./lan-manager'); +module.exports.sunspec = require('./sunspec'); module.exports['nextcloud-talk'] = require('./nextcloud-talk'); module.exports.tuya = require('./tuya'); module.exports.melcloud = require('./melcloud'); diff --git a/server/services/sunspec/api/sunspec.controller.js b/server/services/sunspec/api/sunspec.controller.js new file mode 100644 index 0000000000..119c3a0c61 --- /dev/null +++ b/server/services/sunspec/api/sunspec.controller.js @@ -0,0 +1,116 @@ +const asyncMiddleware = require('../../../api/middlewares/asyncMiddleware'); + +module.exports = function SunSpecController(sunspecManager) { + /** + * @api {get} /api/v1/service/sunspec/node Get SunSpec nodes + * @apiName getDevices + * @apiGroup SunSpec + */ + function getDevices(req, res) { + const nodes = sunspecManager.getDevices(req.query); + res.json(nodes); + } + + /** + * @api {get} /api/v1/service/sunspec/status Get SunSpec Status + * @apiName getStatus + * @apiGroup SunSpec + */ + function getStatus(req, res) { + const status = sunspecManager.getStatus(); + res.json(status); + } + + /** + * @api {get} /api/v1/service/sunspec/config Get SunSpec configuration + * @apiName getConfiguration + * @apiGroup SunSpec + */ + async function getConfiguration(req, res) { + const configuration = await sunspecManager.getConfiguration(); + res.json(configuration); + } + + /** + * @api {post} /api/v1/service/sunspec/config Update configuration + * @apiName updateConfiguration + * @apiGroup SunSpec + */ + async function updateConfiguration(req, res) { + await sunspecManager.updateConfiguration(req.body); + await sunspecManager.disconnect(); + await sunspecManager.connect(); + const configuration = await sunspecManager.getConfiguration(); + sunspecManager.bdpvInit(configuration.bdpvActive); + res.json(configuration); + } + + /** + * @api {post} /api/v1/service/sunspec/connect Connect + * @apiName connect + * @apiGroup SunSpec + */ + async function connect(req, res) { + await sunspecManager.connect(); + res.json({ + success: true, + }); + } + + /** + * @api {post} /api/v1/service/sunspec/disconnect Disconnect + * @apiName disconnect + * @apiGroup SunSpec + */ + async function disconnect(req, res) { + await sunspecManager.disconnect(); + res.json({ + success: true, + }); + } + + /** + * @api {post} /api/v1/service/sunspec/scan Scan SunSpec network + * @apiName scanNetwork + * @apiGroup SunSpec + */ + async function scanNetwork(req, res) { + await sunspecManager.scanNetwork(); + res.json({ + success: true, + }); + } + + return { + 'get /api/v1/service/sunspec/discover': { + authenticated: true, + controller: asyncMiddleware(getDevices), + }, + 'post /api/v1/service/sunspec/discover': { + authenticated: true, + controller: asyncMiddleware(scanNetwork), + }, + 'get /api/v1/service/sunspec/status': { + authenticated: false, + controller: asyncMiddleware(getStatus), + }, + 'get /api/v1/service/sunspec/config': { + authenticated: true, + controller: asyncMiddleware(getConfiguration), + }, + 'post /api/v1/service/sunspec/config': { + authenticated: true, + controller: asyncMiddleware(updateConfiguration), + }, + 'post /api/v1/service/sunspec/connect': { + authenticated: true, + admin: true, + controller: asyncMiddleware(connect), + }, + 'post /api/v1/service/sunspec/disconnect': { + authenticated: true, + admin: true, + controller: asyncMiddleware(disconnect), + }, + }; +}; diff --git a/server/services/sunspec/index.js b/server/services/sunspec/index.js new file mode 100644 index 0000000000..820094d1b3 --- /dev/null +++ b/server/services/sunspec/index.js @@ -0,0 +1,53 @@ +const logger = require('../../utils/logger'); +const SunSpecManager = require('./lib'); +const SunSpecController = require('./api/sunspec.controller'); + +module.exports = function SunSpecService(gladys, serviceId) { + const modbusTCP = require('modbus-serial'); + const { NmapScan } = require('node-sudo-nmap'); + + const sunspecManager = new SunSpecManager(gladys, modbusTCP, NmapScan, serviceId); + + /** + * @public + * @description This function starts the service. + * @example + * gladys.services.sunspec.start(); + */ + async function start() { + logger.log('Starting SunSpec service'); + await sunspecManager.connect(); + const configuration = await sunspecManager.getConfiguration(); + await sunspecManager.bdpvInit(configuration.bdpvActive); + } + + /** + * @public + * @description This function stops the service. + * @example + * gladys.services.sunspec.stop(); + */ + async function stop() { + logger.info('Stopping SunSpec service'); + await sunspecManager.disconnect(); + } + + /** + * @public + * @description Get info if the service is used. + * @returns {Promise} Returns true if the service is used. + * @example + * gladys.services.sunspec.isUsed(); + */ + async function isUsed() { + return sunspecManager.connected && sunspecManager.devices && sunspecManager.devices.length > 0; + } + + return Object.freeze({ + start, + stop, + isUsed, + device: sunspecManager, + controllers: SunSpecController(sunspecManager), + }); +}; diff --git a/server/services/sunspec/lib/bdpv/sunspec.bdpv.js b/server/services/sunspec/lib/bdpv/sunspec.bdpv.js new file mode 100644 index 0000000000..cbed6c96b1 --- /dev/null +++ b/server/services/sunspec/lib/bdpv/sunspec.bdpv.js @@ -0,0 +1,76 @@ +const axios = require('axios'); +const cron = require('node-cron'); +const logger = require('../../../../utils/logger'); +const { CONFIGURATION, BDPV } = require('../sunspec.constants'); +const { DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); + +/** + * @description SunSpec push device to BDPV. + * @example sunspec.bdpvPush(); + */ +async function bdpvPush() { + const index = this.getDevices() + .filter((device) => device.external_id.indexOf('mppt:ac') > 0) + .map((device) => + device.features.filter((deviceFeature) => deviceFeature.type === DEVICE_FEATURE_TYPES.ENERGY_SENSOR.ENERGY), + ) + .filter((deviceFeatures) => deviceFeatures.length > 0) + .map((deviceFeatures) => deviceFeatures[0]) + .map((deviceFeature) => { + const gladysFeature = this.gladys.stateManager.get('deviceFeature', deviceFeature.selector); + return gladysFeature.last_value; + }) + .reduce((total, item) => total + item); + try { + this.bdpvParams.index = index * 1000; // must be in Wh + const response = await this.bdpvClient.get(BDPV.URL, { + params: this.bdpvParams, + }); + logger.info(`BDPV push ${this.bdpvParams.index} (status: ${response.status})`); + } catch (e) { + logger.error(`Fail to push to BDPV: ${e}`); + } +} + +/** + * @description SunSpec init push to BDPV. + * @param {boolean} bdpvActive - Activate BDPV index push. + * @example sunspec.bdpvInit(true); + */ +async function bdpvInit(bdpvActive) { + if (bdpvActive) { + if (this.bdpvClient === undefined) { + const bdpvUsername = await this.gladys.variable.getValue(CONFIGURATION.SUNSPEC_BDPV_USER_NAME, this.serviceId); + const bdpvApiKey = await this.gladys.variable.getValue(CONFIGURATION.SUNSPEC_BDPV_API_KEY, this.serviceId); + if (bdpvUsername === null || bdpvApiKey === null) { + return; + } + + this.bdpvClient = axios.create({ + timeout: 10000, + }); + this.bdpvParams = { + util: bdpvUsername, + apiKey: bdpvApiKey, + typeReleve: BDPV.TYPE_RELEVE, + source: BDPV.SOURCE, + }; + } + + if (this.bdpvTask === undefined) { + this.bdpvTask = cron.schedule(BDPV.CRON, await bdpvPush.bind(this), { + scheduled: false, + }); + logger.info(`Starting BDPV push ${BDPV.CRON}`); + } + + this.bdpvTask.start(); + } else if (this.bdpvTask) { + this.bdpvTask.stop(); + } +} + +module.exports = { + bdpvInit, + bdpvPush, +}; diff --git a/server/services/sunspec/lib/index.js b/server/services/sunspec/lib/index.js new file mode 100644 index 0000000000..08bafcda71 --- /dev/null +++ b/server/services/sunspec/lib/index.js @@ -0,0 +1,36 @@ +const { connect } = require('./sunspec.connect'); +const { disconnect } = require('./sunspec.disconnect'); +const { getStatus } = require('./sunspec.getStatus'); +const { getDevices } = require('./sunspec.getDevices'); +const { getConfiguration } = require('./sunspec.getConfiguration'); +const { updateConfiguration } = require('./sunspec.updateConfiguration'); +const { scanNetwork } = require('./sunspec.scanNetwork'); +const { scanDevices } = require('./sunspec.scanDevices'); +const { poll } = require('./sunspec.poll'); +const { bdpvInit } = require('./bdpv/sunspec.bdpv'); +const { scan } = require('./sunspec.scan'); + +const SunSpecManager = function SunSpecManager(gladys, ModbusTCP, ScannerClass, serviceId) { + this.gladys = gladys; + this.serviceId = serviceId; + this.devices = {}; + this.ScannerClass = ScannerClass; + this.ipMasks = []; + this.modbusClient = new ModbusTCP(); + this.modbuses = new Map(); + this.connected = false; +}; + +SunSpecManager.prototype.connect = connect; +SunSpecManager.prototype.disconnect = disconnect; +SunSpecManager.prototype.getStatus = getStatus; +SunSpecManager.prototype.getConfiguration = getConfiguration; +SunSpecManager.prototype.getDevices = getDevices; +SunSpecManager.prototype.scan = scan; +SunSpecManager.prototype.scanNetwork = scanNetwork; +SunSpecManager.prototype.scanDevices = scanDevices; +SunSpecManager.prototype.updateConfiguration = updateConfiguration; +SunSpecManager.prototype.poll = poll; +SunSpecManager.prototype.bdpvInit = bdpvInit; + +module.exports = SunSpecManager; diff --git a/server/services/sunspec/lib/sunspec.connect.js b/server/services/sunspec/lib/sunspec.connect.js new file mode 100644 index 0000000000..c4ece11921 --- /dev/null +++ b/server/services/sunspec/lib/sunspec.connect.js @@ -0,0 +1,41 @@ +const { DEFAULT } = require('./sunspec.constants'); +const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../utils/constants'); +const { ModbusClient } = require('./utils/sunspec.ModbusClient'); +const logger = require('../../../utils/logger'); + +/** + * @description Initialize service with dependencies and connect to devices. + * @example + * connect(); + */ +async function connect() { + logger.info(`SunSpec: Connecting...`); + + this.sunspecIps = await this.scan(); + + const promises = [...this.sunspecIps].map(async (ip) => { + const modbus = new ModbusClient(this.modbusClient); + try { + await modbus.connect(ip, DEFAULT.MODBUS_PORT); + return modbus; + } catch (e) { + logger.error(e); + } + return null; + }); + this.modbuses = await Promise.all(promises); + + this.connected = true; + + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.CONNECTED, + }); + + logger.info(`SunSpec: Searching devices...`); + + await this.scanNetwork(); +} + +module.exports = { + connect, +}; diff --git a/server/services/sunspec/lib/sunspec.constants.js b/server/services/sunspec/lib/sunspec.constants.js new file mode 100644 index 0000000000..90b0ed8cff --- /dev/null +++ b/server/services/sunspec/lib/sunspec.constants.js @@ -0,0 +1,93 @@ +const SCAN_OPTIONS = { + IP_FAMILY: ['IPv4'], + IP_MASK_OPTION_SEPARATOR: '|', + IP_MASK_VALUE_SEPARATOR: ':', + SCAN_TIMEOUT: 300000, +}; + +const CONFIGURATION = { + SUNSPEC_IP_MASKS: 'SUNSPEC_IP_MASKS', + SUNSPEC_BDPV_ACTIVE: 'SUNSPEC_BDPV_ACTIVE', + SUNSPEC_BDPV_USER_NAME: 'SUNSPEC_BDPV_USER_NAME', + SUNSPEC_BDPV_API_KEY: 'SUNSPEC_BDPV_API_KEY', +}; + +const UNIT_ID = { + SID: 126, +}; + +const TYPE = { + INVERTER_1_PHASE: 0, + INVERTER_3_PHASE: 1, + INVERTER_SPLIT_PHASE: 2, +}; + +const REGISTER = { + SID: 40001, + MODEL_ID: 40003, + MANUFACTURER: 40005, + DEVICE: 40021, + SW_VERSION: 40045, + SERIAL_NUMBER: 40053, + NB_OF_MPPT: 40262, + MPPT_INVERTER_EXTENSION_MODEL: 40253, // 160, FRONIUS: 40254 (-1) SMA: 40622 +}; + +const MODEL = { + COMMON: 1, + INVERTER_1_PHASE: 101, + INVERTER_SPLIT_PHASE: 102, + INVERTER_3_PHASE: 103, + NAMEPLATE: 120, + BASIC_SETTINGS: 121, + MEASUREMENTS_STATUS: 122, + IMMEDIATE_CONTROLS: 123, + STORAGE: 124, + MPPT_INVERTER_EXTENSION: 160, +}; + +const DEFAULT = { + SUNSPEC_MODBUS_MAP: 0x53756e53, + SUNSPEC_COMMON_MODEL: 1, + MODBUS_PORT: 502, + SCAN_DEVICE_TIMEOUT: 5 * 60 * 1000, +}; + +const PARAMS = { + MANUFACTURER: 'MANUFACTURER', + PRODUCT: 'PRODUCT', + SERIAL_NUMBER: 'SERIAL_NUMBER', + SW_VERSION: 'SW_VERSION', + TYPE: 'TYPE', +}; + +const PROPERTY = { + DCA: 'DCA', + DCV: 'DCV', + DCW: 'DCW', + DCWH: 'DCWH', + ACA: 'ACA', + ACV: 'ACV', + ACW: 'ACW', + ACWH: 'ACWH', +}; + +const BDPV = { + URL: 'https://www.bdpv.fr/webservice/majProd/expeditionProd_v3.php', + CRON: '0 23 * * *', + TYPE_RELEVE: 'onduleur', + SOURCE: 'Gladys', +}; + +module.exports = { + SCAN_OPTIONS, + CONFIGURATION, + UNIT_ID, + REGISTER, + MODEL, + DEFAULT, + TYPE, + PARAMS, + PROPERTY, + BDPV, +}; diff --git a/server/services/sunspec/lib/sunspec.disconnect.js b/server/services/sunspec/lib/sunspec.disconnect.js new file mode 100644 index 0000000000..ebebd099cc --- /dev/null +++ b/server/services/sunspec/lib/sunspec.disconnect.js @@ -0,0 +1,29 @@ +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../utils/constants'); +const logger = require('../../../utils/logger'); + +/** + * @description Disconnect sunspec MQTT. + * @example + * sunspec.disconnect(); + */ +async function disconnect() { + logger.debug(`SunSpec: Disconnecting...`); + + if (this.modbus) { + this.modbus.close(() => { + if (this.connected) { + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.STATUS_CHANGE, + }); + } else { + logger.debug('SunSpec: Not connected, by-pass disconnecting'); + } + + this.connected = false; + }); + } +} + +module.exports = { + disconnect, +}; diff --git a/server/services/sunspec/lib/sunspec.getConfiguration.js b/server/services/sunspec/lib/sunspec.getConfiguration.js new file mode 100644 index 0000000000..52b0c339e2 --- /dev/null +++ b/server/services/sunspec/lib/sunspec.getConfiguration.js @@ -0,0 +1,28 @@ +const logger = require('../../../utils/logger'); +const { CONFIGURATION } = require('./sunspec.constants'); + +/** + * @description Getting SunSpec information. + * @returns {Promise} Return Object of information. + * @example + * sunspec.getConfiguration(); + */ +async function getConfiguration() { + logger.debug(`SunSpec: Getting informations...`); + + const bdpvActiveStr = await this.gladys.variable.getValue(CONFIGURATION.SUNSPEC_BDPV_ACTIVE, this.serviceId); + const bdpvActive = bdpvActiveStr !== undefined && bdpvActiveStr === '1'; + const bdpvUsername = await this.gladys.variable.getValue(CONFIGURATION.SUNSPEC_BDPV_USER_NAME, this.serviceId); + const bdpvApiKey = await this.gladys.variable.getValue(CONFIGURATION.SUNSPEC_BDPV_API_KEY, this.serviceId); + + return { + ipMasks: this.ipMasks, + bdpvActive, + bdpvUsername, + bdpvApiKey, + }; +} + +module.exports = { + getConfiguration, +}; diff --git a/server/services/sunspec/lib/sunspec.getDevices.js b/server/services/sunspec/lib/sunspec.getDevices.js new file mode 100644 index 0000000000..7dca2ad961 --- /dev/null +++ b/server/services/sunspec/lib/sunspec.getDevices.js @@ -0,0 +1,278 @@ +const { slugify } = require('../../../utils/slugify'); +const { + getDeviceFeatureExternalId, + getDeviceExternalId, + getDeviceName, + getDeviceFeatureName, +} = require('./utils/sunspec.externalId'); +const { + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_UNITS, + DEVICE_POLL_FREQUENCIES, +} = require('../../../utils/constants'); +const { PROPERTY, PARAMS } = require('./sunspec.constants'); + +/** + * @description Check if keyword matches value. + * @param {string} value - Value to check. + * @param {string} keyword - Keyword to match. + * @returns {boolean} True if keyword matches value. + * @example + * const res = sunspecManager.match('test', 'te'); + */ +function match(value, keyword) { + return value ? value.toLowerCase().includes(keyword.toLowerCase()) : true; +} + +/** + * @description Return array of devices. + * @param {object} pagination - Filtering and ordering. + * @param {string} pagination.orderDir - Ordering. + * @param {string} pagination.search - Keyword to filter devices. + * @returns {Array} Return list of devices. + * @example + * const devices = sunspecManager.getDevices(); + */ +function getDevices({ orderDir, search } = {}) { + return this.devices + .filter((device) => (search ? match(device.manufacturer, search) || match(device.product, search) : true)) + .map((device) => { + const newDevice = { + name: getDeviceName(device), + selector: slugify(getDeviceExternalId(device)), + model: `${getDeviceName(device)}`, + service_id: this.serviceId, + external_id: getDeviceExternalId(device), + features: [], + should_poll: true, + poll_frequency: DEVICE_POLL_FREQUENCIES.EVERY_MINUTES, + params: [ + { + name: PARAMS.MANUFACTURER, + value: device.manufacturer, + }, + { + name: PARAMS.PRODUCT, + value: device.product, + }, + { + name: PARAMS.SERIAL_NUMBER, + value: device.serialNumber, + }, + { + name: PARAMS.SW_VERSION, + value: device.swVersion, + }, + ], + }; + + if (device.mppt) { + newDevice.features.push({ + name: getDeviceFeatureName({ + ...device, + property: PROPERTY.DCA, + }), + selector: slugify( + getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.DCA, + }), + ), + category: DEVICE_FEATURE_CATEGORIES.PV, + type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.CURRENT, + external_id: getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.DCA, + }), + read_only: true, + unit: DEVICE_FEATURE_UNITS.AMPERE, + has_feedback: false, + min: 0, + max: 400, + last_value: 0, + }); + + newDevice.features.push({ + name: getDeviceFeatureName({ + ...device, + property: PROPERTY.DCV, + }), + selector: slugify( + getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.DCV, + }), + ), + category: DEVICE_FEATURE_CATEGORIES.PV, + type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.VOLTAGE, + external_id: getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.DCV, + }), + read_only: true, + unit: DEVICE_FEATURE_UNITS.VOLT, + has_feedback: false, + min: 0, + max: 400, + last_value: 0, + }); + + newDevice.features.push({ + name: getDeviceFeatureName({ + ...device, + property: PROPERTY.DCW, + }), + selector: slugify( + getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.DCW, + }), + ), + category: DEVICE_FEATURE_CATEGORIES.PV, + type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.POWER, + external_id: getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.DCW, + }), + read_only: true, + unit: DEVICE_FEATURE_UNITS.WATT, + has_feedback: false, + min: 0, + max: 10000, + last_value: 0, + }); + + newDevice.features.push({ + name: getDeviceFeatureName({ + ...device, + property: PROPERTY.DCWH, + }), + selector: slugify( + getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.DCWH, + }), + ), + category: DEVICE_FEATURE_CATEGORIES.PV, + type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.ENERGY, + external_id: getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.DCWH, + }), + read_only: true, + unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR, + has_feedback: false, + min: 0, + max: 1000000, + last_value: 0, + }); + } else { + newDevice.features.push({ + name: getDeviceFeatureName({ + ...device, + property: PROPERTY.ACA, + }), + selector: slugify( + getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.ACA, + }), + ), + category: DEVICE_FEATURE_CATEGORIES.PV, + type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.CURRENT, + external_id: getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.ACA, + }), + read_only: true, + unit: DEVICE_FEATURE_UNITS.AMPERE, + has_feedback: false, + min: 0, + max: 400, + last_value: 0, + }); + + newDevice.features.push({ + name: getDeviceFeatureName({ + ...device, + property: PROPERTY.ACV, + }), + selector: slugify( + getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.ACV, + }), + ), + category: DEVICE_FEATURE_CATEGORIES.PV, + type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.VOLTAGE, + external_id: getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.ACV, + }), + read_only: true, + unit: DEVICE_FEATURE_UNITS.VOLT, + has_feedback: false, + min: 0, + max: 400, + last_value: 0, + }); + + newDevice.features.push({ + name: getDeviceFeatureName({ + ...device, + property: PROPERTY.ACW, + }), + selector: slugify( + getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.ACW, + }), + ), + category: DEVICE_FEATURE_CATEGORIES.PV, + type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.POWER, + external_id: getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.ACW, + }), + read_only: true, + unit: DEVICE_FEATURE_UNITS.VOLT_AMPERE, + has_feedback: false, + min: 0, + max: 10000, + last_value: 0, + }); + + newDevice.features.push({ + name: getDeviceFeatureName({ + ...device, + property: PROPERTY.ACWH, + }), + selector: slugify( + getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.ACWH, + }), + ), + category: DEVICE_FEATURE_CATEGORIES.PV, + type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.ENERGY, + external_id: getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.ACWH, + }), + read_only: true, + unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR, + has_feedback: false, + min: 0, + max: 1000000, + last_value: 0, + }); + } + return newDevice; + }) + .filter((newDevice) => newDevice.features && newDevice.features.length > 0); +} + +module.exports = { + getDevices, +}; diff --git a/server/services/sunspec/lib/sunspec.getStatus.js b/server/services/sunspec/lib/sunspec.getStatus.js new file mode 100644 index 0000000000..98113286d3 --- /dev/null +++ b/server/services/sunspec/lib/sunspec.getStatus.js @@ -0,0 +1,20 @@ +const logger = require('../../../utils/logger'); + +/** + * @description Getting SunSpec status. + * @returns {object} Return Object of status. + * @example + * sunspec.getStatus(); + */ +function getStatus() { + logger.debug(`SunSpec: Getting status...`); + + return { + connected: this.connected, + sunspecIps: this.sunspecIps, + }; +} + +module.exports = { + getStatus, +}; diff --git a/server/services/sunspec/lib/sunspec.poll.js b/server/services/sunspec/lib/sunspec.poll.js new file mode 100644 index 0000000000..1102c90968 --- /dev/null +++ b/server/services/sunspec/lib/sunspec.poll.js @@ -0,0 +1,12 @@ +/** + * @description SunSpec poll device function. + * @example + * sunspec.poll(); + */ +async function poll() { + this.scanDevices(); +} + +module.exports = { + poll, +}; diff --git a/server/services/sunspec/lib/sunspec.scan.js b/server/services/sunspec/lib/sunspec.scan.js new file mode 100644 index 0000000000..61b5d8704f --- /dev/null +++ b/server/services/sunspec/lib/sunspec.scan.js @@ -0,0 +1,119 @@ +const os = require('os'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../utils/constants'); +const logger = require('../../../utils/logger'); +const { CONFIGURATION, SCAN_OPTIONS, DEFAULT } = require('./sunspec.constants'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); + +/** + * @description Scan for network devices. + * @returns {Promise} The discovered devices. + * @example + * await lanManager.scan(); + */ +async function scan() { + if (this.scanning) { + logger.warn(`Sunspec already scanning for devices...`); + return []; + } + logger.info(`Sunspec starts scanning devices...`); + + const rawIPMasks = await this.gladys.variable.getValue(CONFIGURATION.SUNSPEC_IP_MASKS, this.serviceId); + if (rawIPMasks !== null) { + const loadedIPMasks = JSON.parse(rawIPMasks); + this.ipMasks = []; + loadedIPMasks.forEach((option) => { + const mask = { ...option, networkInterface: false }; + this.ipMasks.push(mask); + }); + } else { + throw new ServiceNotConfiguredError(); + } + + // Complete masks with network interfaces + const networkInterfaces = os.networkInterfaces(); + Object.keys(networkInterfaces).forEach((interfaceName) => { + const interfaces = networkInterfaces[interfaceName]; + + interfaces.forEach((interfaceDetails) => { + const { family, cidr: mask, internal } = interfaceDetails; + + // Filter on IP family + if (SCAN_OPTIONS.IP_FAMILY.includes(family) && !internal) { + const boundMask = this.ipMasks.find((currentMask) => currentMask.mask === mask); + // Add not already bound masks + if (!boundMask) { + // Check subnet mask + const subnetMask = mask.split('/')[1]; + // Default disable for large IP ranges (minimum value /24 to enable interface) + const enabled = Number.parseInt(subnetMask, 10) >= 24; + const networkInterfaceMask = { + mask, + name: interfaceName, + networkInterface: true, + enabled, + }; + this.ipMasks.push(networkInterfaceMask); + } else { + // Force override with real information + boundMask.name = interfaceName; + boundMask.networkInterface = true; + } + } + }); + }); + + this.scanning = true; + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.SCANNING, + payload: { + scanning: true, + }, + }); + + const enabledMasks = this.ipMasks.filter((mask) => mask.enabled).map(({ mask }) => mask); + this.scanner = new this.ScannerClass(enabledMasks, `-p${DEFAULT.MODBUS_PORT}`); + this.scanner.scanTimeout = SCAN_OPTIONS.SCAN_TIMEOUT; + + const scanDone = (discoveredDevices, success) => { + this.scanning = false; + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.SCANNING, + payload: { + scanning: false, + success, + }, + }); + }; + + const result = await new Promise((resolve) => { + const onError = (err) => { + if (err !== 'Scan cancelled') { + logger.error('Sunspec fails to discover devices over network -', err); + } + scanDone([], false); + return resolve([]); + }; + + const onSuccess = (foundDevices = []) => { + const discoveredDevices = foundDevices.filter((device) => device.openPorts.length > 0); + logger.info(`Sunspec discovers ${discoveredDevices.length} devices`); + scanDone(discoveredDevices, true); + return resolve(discoveredDevices); + }; + + this.scanner.on('error', onError); + this.scanner.on('complete', onSuccess); + }); + + // this.stop(); + + const uniqueResult = new Set(); + result.forEach((element) => { + uniqueResult.add(element.ip); + }); + return uniqueResult; +} + +module.exports = { + scan, +}; diff --git a/server/services/sunspec/lib/sunspec.scanDevices.js b/server/services/sunspec/lib/sunspec.scanDevices.js new file mode 100644 index 0000000000..fa993aaf63 --- /dev/null +++ b/server/services/sunspec/lib/sunspec.scanDevices.js @@ -0,0 +1,88 @@ +const { EVENTS } = require('../../../utils/constants'); +const logger = require('../../../utils/logger'); +const { MODEL, PROPERTY } = require('./sunspec.constants'); +const { getDeviceFeatureExternalId } = require('./utils/sunspec.externalId'); +const { ModelFactory } = require('./utils/sunspec.ModelFactory'); + +/** + * @description Scan SunSpec devices. + * @example + * sunspec.scanDevices(); + */ +async function scanDevices() { + logger.debug(`SunSpec: Scanning devices...`); + + Object.values(this.devices) + .filter((device) => device.mppt === undefined) + .forEach(async (device) => { + const values = ModelFactory.createModel(await device.modbus.readModel(device.valueModel)); + Object.entries(values).forEach(async (entry) => { + const [name, value] = entry; + const externalId = getDeviceFeatureExternalId({ + ...device, + property: name, + }); + if (this.gladys.stateManager.get('deviceFeatureByExternalId', externalId)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: externalId, + state: value, + }); + } + }, this); + }, this); + + Object.values(this.devices) + .filter((device) => device.mppt !== undefined) + .forEach(async (device) => { + // @ts-ignore + const { mppt } = ModelFactory.createModel(await device.modbus.readModel(MODEL.MPPT_INVERTER_EXTENSION)); + const { DCA, DCV, DCW, DCWH } = mppt[device.mppt - 1]; + + let externalId = getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.DCA, + }); + if (this.gladys.stateManager.get('deviceFeatureByExternalId', externalId)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: externalId, + state: DCA, + }); + } + externalId = getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.DCV, + }); + if (this.gladys.stateManager.get('deviceFeatureByExternalId', externalId)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: externalId, + state: DCV, + }); + } + externalId = getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.DCW, + }); + if (this.gladys.stateManager.get('deviceFeatureByExternalId', externalId)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: externalId, + state: DCW, + }); + } + externalId = getDeviceFeatureExternalId({ + ...device, + property: PROPERTY.DCWH, + }); + if (this.gladys.stateManager.get('deviceFeatureByExternalId', externalId)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: externalId, + state: DCWH, + }); + } + }, this); + + logger.debug(`SunSpec: Scanning devices done`); +} + +module.exports = { + scanDevices, +}; diff --git a/server/services/sunspec/lib/sunspec.scanNetwork.js b/server/services/sunspec/lib/sunspec.scanNetwork.js new file mode 100644 index 0000000000..30b06e8153 --- /dev/null +++ b/server/services/sunspec/lib/sunspec.scanNetwork.js @@ -0,0 +1,58 @@ +const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../utils/constants'); +const logger = require('../../../utils/logger'); +const { MODEL, REGISTER } = require('./sunspec.constants'); +const { ModelFactory } = require('./utils/sunspec.ModelFactory'); + +/** + * @description Scan SunSpec product Network. + * @example + * sunspec.scanNetwork(); + */ +async function scanNetwork() { + logger.debug(`SunSpec: Scanning network...`); + + this.devices = []; + + const promises = this.modbuses.map(async (modbus) => { + const { manufacturer, product, swVersion, serialNumber } = ModelFactory.createModel( + await modbus.readModel(MODEL.COMMON), + ); + logger.info( + `SunSpec: Found device ${manufacturer} ${product} with serial number ${serialNumber} and software version ${swVersion}`, + ); + + // SMA = N <> Fronius = N - 2 + const nbOfMPPT = (await modbus.readRegisterAsInt16(REGISTER.NB_OF_MPPT)) - (manufacturer === 'Fronius' ? 2 : 0); + + // AC device + this.devices.push({ + manufacturer, + product, + serialNumber, + swVersion, + valueModel: modbus.getValueModel(), + modbus, + }); + + // One par DC (MPPT) device + for (let i = 0; i < nbOfMPPT; i += 1) { + this.devices.push({ + manufacturer, + product, + serialNumber, + swVersion, + mppt: i + 1, + modbus, + }); + } + }); + await Promise.all(promises); + + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.STATUS_CHANGE, + }); +} + +module.exports = { + scanNetwork, +}; diff --git a/server/services/sunspec/lib/sunspec.updateConfiguration.js b/server/services/sunspec/lib/sunspec.updateConfiguration.js new file mode 100644 index 0000000000..c7f838eb78 --- /dev/null +++ b/server/services/sunspec/lib/sunspec.updateConfiguration.js @@ -0,0 +1,42 @@ +const logger = require('../../../utils/logger'); +const { CONFIGURATION } = require('./sunspec.constants'); + +/** + * @description Update SunSpec configuration. + * @param {object} configuration - The configuration data. + * @param {string} [configuration.ipMasks] - The CIDRs of the SunSpec devices to scan. + * @param {string} [configuration.bdpvActive] - The host:port of the SunSpec device. + * @param {string} [configuration.bdpvUsername] - The host:port of the SunSpec device. + * @param {string} [configuration.bdpvApiKey] - The host:port of the SunSpec device. + * @example + * sunspec.updateConfiguration({ ipMasks: ['192.168.1.0/24'] }); + */ +async function updateConfiguration({ + ipMasks = undefined, + bdpvActive = undefined, + bdpvUsername = undefined, + bdpvApiKey = undefined, +} = {}) { + logger.debug(`SunSpec : Updating configuration...`); + + if (ipMasks) { + await this.gladys.variable.setValue(CONFIGURATION.SUNSPEC_IP_MASKS, JSON.stringify(ipMasks), this.serviceId); + this.ipMasks = ipMasks; + } + + if (bdpvActive !== undefined) { + await this.gladys.variable.setValue(CONFIGURATION.SUNSPEC_BDPV_ACTIVE, bdpvActive ? '1' : '0', this.serviceId); + } + + if (bdpvUsername) { + await this.gladys.variable.setValue(CONFIGURATION.SUNSPEC_BDPV_USER_NAME, bdpvUsername, this.serviceId); + } + + if (bdpvApiKey) { + await this.gladys.variable.setValue(CONFIGURATION.SUNSPEC_BDPV_API_KEY, bdpvApiKey, this.serviceId); + } +} + +module.exports = { + updateConfiguration, +}; diff --git a/server/services/sunspec/lib/utils/sunspec.ModbusClient.js b/server/services/sunspec/lib/utils/sunspec.ModbusClient.js new file mode 100644 index 0000000000..d8532b1236 --- /dev/null +++ b/server/services/sunspec/lib/utils/sunspec.ModbusClient.js @@ -0,0 +1,134 @@ +const logger = require('../../../../utils/logger'); +const { REGISTER, DEFAULT, MODEL } = require('../sunspec.constants'); +const { trimString } = require('./sunspec.utils'); + +class ModbusClient { + constructor(modbusClientApi) { + this.modbusClientApi = modbusClientApi; + this.models = {}; + } + + async connect(sunspecHost, sunspecPort) { + try { + await this.modbusClientApi.connectTCP(sunspecHost, { port: sunspecPort, timeout: 10000 }); + logger.info(`SunSpec service connected`); + // this.modbusClientApi.setID(UNIT_ID.SID); + const sid = await this.readRegisterAsInt32(REGISTER.SID); + if (sid !== DEFAULT.SUNSPEC_MODBUS_MAP) { + logger.error(`Invalid SID received. Expected ${DEFAULT.SUNSPEC_MODBUS_MAP} but got ${sid}`); + return; + } + const model = await this.readRegisterAsInt16(REGISTER.MODEL_ID); + if (model !== DEFAULT.SUNSPEC_COMMON_MODEL) { + logger.error(`Invalid SunSpec Model received. Expected ${DEFAULT.SUNSPEC_COMMON_MODEL} but got ${model}`); + return; + } + this.models[1] = { + registerStart: REGISTER.MODEL_ID, + registerLength: await this.readRegisterAsInt16(REGISTER.MODEL_ID + 1), + }; + let nextModel; + let nextModelLength; + let registerId = REGISTER.MODEL_ID + 1; + registerId += (await this.readRegisterAsInt16(registerId)) + 1; + // eslint-disable-next-line no-constant-condition + while (true) { + // eslint-disable-next-line no-await-in-loop + nextModel = await this.readRegisterAsInt16(registerId); + if (nextModel === 0xffff) { + break; + } + // eslint-disable-next-line no-await-in-loop + nextModelLength = await this.readRegisterAsInt16(registerId + 1); + // eslint-disable-next-line no-await-in-loop + this.models[nextModel] = { + registerStart: registerId, + registerLength: nextModelLength, + }; + registerId += nextModelLength + 2; + } + } catch (e) { + throw new Error(`Unable to connect Sunspec device ${sunspecHost}:${sunspecPort} - ${e}`); + } + } + + async close() { + return new Promise((resolve, reject) => { + this.modbusClientApi.close(() => { + logger.info('SunSpec service disconnected'); + resolve(); + }); + }); + } + + getValueModel() { + if (this.models[MODEL.INVERTER_1_PHASE]) { + return MODEL.INVERTER_1_PHASE; + } + if (this.models[MODEL.INVERTER_3_PHASE]) { + return MODEL.INVERTER_3_PHASE; + } + return MODEL.INVERTER_SPLIT_PHASE; + } + + async readModel(modelId) { + const { registerStart, registerLength } = this.models[modelId]; + return this.readRegister(registerStart, registerLength); + } + + async readRegister(registerId, registerLength) { + return new Promise((resolve, reject) => { + this.modbusClientApi.readHoldingRegisters(registerId - 1, registerLength, (err, data) => { + if (err) { + reject(err); + } else { + resolve(data.buffer); + } + }); + }); + } + + async readRegisterAsString(registerId, registerLength) { + return new Promise((resolve, reject) => { + this.modbusClientApi.readHoldingRegisters(registerId - 1, registerLength, (err, data) => { + if (err) { + reject(err); + } else { + const res = data.buffer.toString(); + resolve(trimString(res)); + } + }); + }); + } + + async readRegisterAsInt16(registerId) { + return new Promise((resolve, reject) => { + this.modbusClientApi.readHoldingRegisters(registerId - 1, 1, (err, data) => { + if (err) { + reject(err); + } else { + const res = data.data[0]; + resolve(res); + } + }); + }); + } + + async readRegisterAsInt32(registerId) { + return new Promise((resolve, reject) => { + this.modbusClientApi.readHoldingRegisters(registerId - 1, 2, (err, data) => { + if (err) { + reject(err); + } else { + // eslint-disable-next-line no-bitwise + const res = (data.data[0] << 16) | data.data[1]; + resolve(res); + } + }); + }); + } +} + +module.exports = { + ModbusClient, +}; diff --git a/server/services/sunspec/lib/utils/sunspec.ModelFactory.js b/server/services/sunspec/lib/utils/sunspec.ModelFactory.js new file mode 100644 index 0000000000..0a79e2194e --- /dev/null +++ b/server/services/sunspec/lib/utils/sunspec.ModelFactory.js @@ -0,0 +1,160 @@ +const { MODEL } = require('../sunspec.constants'); +const { readString, readUInt16, readUInt32, readInt16 } = require('./sunspec.utils'); + +class ModelFactory { + static readMPPT(data, res, mppt, dcaSf, dcvSf, dcwSf, dcwhSf) { + let localOffset = res.offset; + + res.mppt.push({}); + + res.mppt[mppt].ID = readUInt16(data, localOffset); + localOffset += 1; + res.mppt[mppt].IDStr = readString(data, localOffset, 8); + localOffset += 8; + res.mppt[mppt].DCA = (readUInt16(data, localOffset) * 10 ** dcaSf).toFixed(2); + localOffset += 1; + res.mppt[mppt].DCV = (readUInt16(data, localOffset) * 10 ** dcvSf).toFixed(0); + localOffset += 1; + res.mppt[mppt].DCW = (readUInt16(data, localOffset) * 10 ** dcwSf).toFixed(0); + localOffset += 1; + // Sunspec = Wh, Gladys kWh + res.mppt[mppt].DCWH = ((readUInt32(data, localOffset) * 10 ** dcwhSf) / 1000).toFixed(0); + localOffset += 2; + res.mppt[mppt].Tms = readUInt32(data, localOffset); + localOffset += 2; + res.mppt[mppt].Tmp = readUInt16(data, localOffset); + localOffset += 1; + res.mppt[mppt].DCSt = readUInt16(data, localOffset); + localOffset += 1; + res.mppt[mppt].DCEvt = readUInt16(data, localOffset); + localOffset += 2; + + localOffset += 4; + + res.offset = localOffset; + + return res; + } + + static createModel(data) { + const type = readUInt16(data, 0); + // const length = readUInt16(data, 1); + + const res = {}; + switch (type) { + case MODEL.COMMON: + res.manufacturer = readString(data, 2, 16); + res.product = readString(data, 18, 16); + res.options = readString(data, 34, 8); + res.swVersion = readString(data, 42, 8); + res.serialNumber = readString(data, 50, 16); + break; + case MODEL.INVERTER_1_PHASE: { + // eslint-disable-next-line no-case-declarations + const acaSf = readInt16(data, 6); + res.ACA = (readUInt16(data, 2) * 10 ** acaSf).toFixed(2); + + // eslint-disable-next-line no-case-declarations + const acvSf = readInt16(data, 13); + res.ACV = (readUInt16(data, 10) * 10 ** acvSf).toFixed(0); + + // eslint-disable-next-line no-case-declarations + const acwSf = readInt16(data, 15); + res.ACW = (readInt16(data, 14) * 10 ** acwSf).toFixed(0); + + // eslint-disable-next-line no-case-declarations + // const acfSf = readInt16(data, 17); + // res.ACF = (readUInt16(data, 16) * 10 ** acfSf).toFixed(0); + + // eslint-disable-next-line no-case-declarations + // const acvaSf = readInt16(data, 19); + // res.ACVA = (readInt16(data, 18) * 10 ** acvaSf).toFixed(0); + + // eslint-disable-next-line no-case-declarations + // const acvarSf = readInt16(data, 21); + // res.ACVAR = (readInt16(data, 20) * 10 ** acvarSf).toFixed(0); + + // eslint-disable-next-line no-case-declarations + // const acpfSf = readInt16(data, 13); + // res.ACPF = (readInt16(data, 22) * 10 ** acpfSf).toFixed(0); + + // eslint-disable-next-line no-case-declarations + const acwhSf = readInt16(data, 26); + res.ACWH = ((readUInt32(data, 24) * 10 ** acwhSf) / 1000).toFixed(0); + break; + } + case MODEL.INVERTER_SPLIT_PHASE: + case MODEL.INVERTER_3_PHASE: { + // eslint-disable-next-line no-case-declarations + const acaSf = readInt16(data, 6); + res.ACA = (readUInt16(data, 2) * 10 ** acaSf).toFixed(2); + // res.ACA_A = (readUInt16(data, 3) * 10 ** acaSf).toFixed(2); + // res.ACA_B = (readUInt16(data, 4) * 10 ** acaSf).toFixed(2); + // res.ACA_C = (readUInt16(data, 5) * 10 ** acaSf).toFixed(2); + + // eslint-disable-next-line no-case-declarations + const acvSf = readInt16(data, 13); + // res.ACV_AB = (readUInt16(data, 7) * 10 ** acvSf).toFixed(0); + // res.ACV_BC = (readUInt16(data, 8) * 10 ** acvSf).toFixed(0); + // res.ACV_CA = (readUInt16(data, 9) * 10 ** acvSf).toFixed(0); + // res.ACV_AN = (readUInt16(data, 10) * 10 ** acvSf).toFixed(0); + // res.ACV_BN = (readUInt16(data, 11) * 10 ** acvSf).toFixed(0); + // res.ACV_CN = (readUInt16(data, 12) * 10 ** acvSf).toFixed(0); + const acvA = readUInt16(data, 10) * 10 ** acvSf; + const acvB = readUInt16(data, 11) * 10 ** acvSf; + const acvC = readUInt16(data, 12) * 10 ** acvSf; + res.ACV = ((acvA + acvB + acvC) / 3).toFixed(0); + + // eslint-disable-next-line no-case-declarations + const acwSf = readInt16(data, 15); + res.ACW = (readInt16(data, 14) * 10 ** acwSf).toFixed(0); + + // eslint-disable-next-line no-case-declarations + // const acfSf = readInt16(data, 17); + // res.ACF = (readUInt16(data, 16) * 10 ** acfSf).toFixed(0); + + // eslint-disable-next-line no-case-declarations + // const acvaSf = readInt16(data, 19); + // res.ACVA = (readInt16(data, 18) * 10 ** acvaSf).toFixed(0); + + // eslint-disable-next-line no-case-declarations + // const acvarSf = readInt16(data, 21); + // res.ACVAR = (readInt16(data, 20) * 10 ** acvarSf).toFixed(0); + + // eslint-disable-next-line no-case-declarations + // const acpfSf = readInt16(data, 13); + // res.ACPF = (readInt16(data, 22) * 10 ** acpfSf).toFixed(0); + + // eslint-disable-next-line no-case-declarations + const acwhSf = readInt16(data, 26); + res.ACWH = ((readUInt32(data, 24) * 10 ** acwhSf) / 1000).toFixed(0); + break; + } + case MODEL.MPPT_INVERTER_EXTENSION: { + const DCA_SF = readInt16(data, 2); + const DCV_SF = readInt16(data, 3); + const DCW_SF = readInt16(data, 4); + const DCWH_SF = readInt16(data, 5); + res.Evt = readUInt32(data, 6); + res.N = readUInt16(data, 8); + res.TmsPer = readUInt16(data, 9); + + res.offset = 10; + res.mppt = []; + + for (let i = 0; i < Math.min(res.N, 2); i += 1) { + this.readMPPT(data, res, i, DCA_SF, DCV_SF, DCW_SF, DCWH_SF); + } + + break; + } + default: + return new Error('Model ID not supported'); + } + return res; + } +} + +module.exports = { + ModelFactory, +}; diff --git a/server/services/sunspec/lib/utils/sunspec.externalId.js b/server/services/sunspec/lib/utils/sunspec.externalId.js new file mode 100644 index 0000000000..de0b9940bd --- /dev/null +++ b/server/services/sunspec/lib/utils/sunspec.externalId.js @@ -0,0 +1,54 @@ +/** + * @description Return name of device. + * @param {object} device - The sunspec device. + * @returns {string} Return name. + * @example + * getDeviceName(device); + */ +function getDeviceName(device) { + const type = device.mppt !== undefined ? `DC ${device.mppt}` : `AC`; + return `${device.manufacturer} ${device.product} [${type}]`; +} + +/** + * @description Return external id of device. + * @param {object} device - The sunspec device. + * @returns {string} Return external id. + * @example + * getDeviceExternalId(device); + */ +function getDeviceExternalId(device) { + const type = device.mppt !== undefined ? `dc${device.mppt}` : `ac`; + return `sunspec:serialnumber:${device.serialNumber}:mppt:${type}`; +} + +/** + * @description Return name of device feature. + * @param {object} feature - The sunspec devcie feature. + * @returns {string} Return name. + * @example + * getDeviceFeatureName(feature); + */ +function getDeviceFeatureName(feature) { + const type = feature.mppt !== undefined ? `DC ${feature.mppt}` : `AC`; + return `${feature.manufacturer} ${feature.product} [${type}] - ${feature.property}`; +} + +/** + * @description Return external id of deviceFeature. + * @param {object} device - The sunspec device property. + * @returns {string} Return external id. + * @example + * getDeviceFeatureExternalId({}); + */ +function getDeviceFeatureExternalId(device) { + const type = device.mppt !== undefined ? `dc${device.mppt}` : `ac`; + return `sunspec:serialnumber:${device.serialNumber}:mppt:${type}:property:${device.property}`; +} + +module.exports = { + getDeviceName, + getDeviceExternalId, + getDeviceFeatureName, + getDeviceFeatureExternalId, +}; diff --git a/server/services/sunspec/lib/utils/sunspec.utils.js b/server/services/sunspec/lib/utils/sunspec.utils.js new file mode 100644 index 0000000000..4cfc032f43 --- /dev/null +++ b/server/services/sunspec/lib/utils/sunspec.utils.js @@ -0,0 +1,62 @@ +/** + * @description Trim the input string. + * @param {string} value - The value to trim. + * @returns {string} The value without space character. + * @example trimString('SunSpec ') + */ +function trimString(value) { + return value.replace(/^[\s\uFEFF\xA0\0]+|[\s\uFEFF\xA0\0]+$/g, ''); +} + +/** + * @description Read a string from data buffer. + * @param {Buffer} data - The sunspec register data buffer. + * @param {number} offset - The sunspec offset. + * @param {number} length - The sunspec length. + * @returns {string} The string. + * @example readString(Buffer, 1, 2) + */ +function readString(data, offset, length) { + return trimString(data.subarray(offset * 2, (offset + length) * 2).toString()); +} + +/** + * @description Read an integer from data buffer. + * @param {Buffer} data - The sunspec register data buffer. + * @param {number} offset - The sunspec offset. + * @returns {number} The integer. + * @example readUInt16(Buffer, 1) + */ +function readUInt16(data, offset) { + return data.readUInt16BE(offset * 2); +} + +/** + * @description Read an integer from data buffer. + * @param {Buffer} data - The sunspec register data buffer. + * @param {number} offset - The sunspec offset. + * @returns {number} The integer. + * @example readInt16(Buffer, 1) + */ +function readInt16(data, offset) { + return data.readInt16BE(offset * 2); +} + +/** + * @description Read an integer from data buffer. + * @param {Buffer} data - The sunspec register data buffer. + * @param {number} offset - The sunspec offset. + * @returns {number} The integer. + * @example readUInt32(Buffer, 1) + */ +function readUInt32(data, offset) { + return data.readUInt32BE(offset * 2); +} + +module.exports = { + readString, + readInt16, + readUInt16, + readUInt32, + trimString, +}; diff --git a/server/services/sunspec/package-lock.json b/server/services/sunspec/package-lock.json new file mode 100644 index 0000000000..e3370d5dc4 --- /dev/null +++ b/server/services/sunspec/package-lock.json @@ -0,0 +1,413 @@ +{ + "name": "gladys-sunspec", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gladys-sunspec", + "version": "1.0.0", + "cpu": [ + "x64", + "arm", + "arm64" + ], + "os": [ + "darwin", + "linux", + "freebsd", + "win32" + ], + "dependencies": { + "axios": "^1.4.0", + "modbus-serial": "^8.0.11", + "node-cron": "^3.0.2", + "node-sudo-nmap": "^4.0.4" + } + }, + "node_modules/@serialport/binding-mock": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@serialport/binding-mock/-/binding-mock-10.2.2.tgz", + "integrity": "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==", + "dependencies": { + "@serialport/bindings-interface": "^1.2.1", + "debug": "^4.3.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@serialport/bindings-cpp": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@serialport/bindings-cpp/-/bindings-cpp-10.8.0.tgz", + "integrity": "sha512-OMQNJz5kJblbmZN5UgJXLwi2XNtVLxSKmq5VyWuXQVsUIJD4l9UGHnLPqM5LD9u3HPZgDI5w7iYN7gxkQNZJUw==", + "hasInstallScript": true, + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "@serialport/parser-readline": "^10.2.1", + "debug": "^4.3.2", + "node-addon-api": "^5.0.0", + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=12.17.0 <13.0 || >=14.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-interface": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz", + "integrity": "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==", + "engines": { + "node": "^12.22 || ^14.13 || >=16" + } + }, + "node_modules/@serialport/parser-byte-length": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-byte-length/-/parser-byte-length-10.5.0.tgz", + "integrity": "sha512-eHhr4lHKboq1OagyaXAqkemQ1XyoqbLQC8XJbvccm95o476TmEdW5d7AElwZV28kWprPW68ZXdGF2VXCkJgS2w==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-cctalk": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-cctalk/-/parser-cctalk-10.5.0.tgz", + "integrity": "sha512-Iwsdr03xmCKAiibLSr7b3w6ZUTBNiS+PwbDQXdKU/clutXjuoex83XvsOtYVcNZmwJlVNhAUbkG+FJzWwIa4DA==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-delimiter": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-10.5.0.tgz", + "integrity": "sha512-/uR/yT3jmrcwnl2FJU/2ySvwgo5+XpksDUR4NF/nwTS5i3CcuKS+FKi/tLzy1k8F+rCx5JzpiK+koqPqOUWArA==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-inter-byte-timeout": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-10.5.0.tgz", + "integrity": "sha512-WPvVlSx98HmmUF9jjK6y9mMp3Wnv6JQA0cUxLeZBgS74TibOuYG3fuUxUWGJALgAXotOYMxfXSezJ/vSnQrkhQ==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-packet-length": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-packet-length/-/parser-packet-length-10.5.0.tgz", + "integrity": "sha512-jkpC/8w4/gUBRa2Teyn7URv1D7T//0lGj27/4u9AojpDVXsR6dtdcTG7b7dNirXDlOrSLvvN7aS5/GNaRlEByw==", + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@serialport/parser-readline": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-10.5.0.tgz", + "integrity": "sha512-0aXJknodcl94W9zSjvU+sLdXiyEG2rqjQmvBWZCr8wJZjWEtv3RgrnYiWq4i2OTOyC8C/oPK8ZjpBjQptRsoJQ==", + "dependencies": { + "@serialport/parser-delimiter": "10.5.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-ready": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-ready/-/parser-ready-10.5.0.tgz", + "integrity": "sha512-QIf65LTvUoxqWWHBpgYOL+soldLIIyD1bwuWelukem2yDZVWwEjR288cLQ558BgYxH4U+jLAQahhqoyN1I7BaA==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-regex": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-regex/-/parser-regex-10.5.0.tgz", + "integrity": "sha512-9jnr9+PCxRoLjtGs7uxwsFqvho+rxuJlW6ZWSB7oqfzshEZWXtTJgJRgac/RuLft4hRlrmRz5XU40i3uoL4HKw==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-slip-encoder": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-slip-encoder/-/parser-slip-encoder-10.5.0.tgz", + "integrity": "sha512-wP8m+uXQdkWSa//3n+VvfjLthlabwd9NiG6kegf0fYweLWio8j4pJRL7t9eTh2Lbc7zdxuO0r8ducFzO0m8CQw==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-spacepacket": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-spacepacket/-/parser-spacepacket-10.5.0.tgz", + "integrity": "sha512-BEZ/HAEMwOd8xfuJSeI/823IR/jtnThovh7ils90rXD4DPL1ZmrP4abAIEktwe42RobZjIPfA4PaVfyO0Fjfhg==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-10.5.0.tgz", + "integrity": "sha512-gbcUdvq9Kyv2HsnywS7QjnEB28g+6OGB5Z8TLP7X+UPpoMIWoUsoQIq5Kt0ZTgMoWn3JGM2lqwTsSHF+1qhniA==", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "debug": "^4.3.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/modbus-serial": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/modbus-serial/-/modbus-serial-8.0.11.tgz", + "integrity": "sha512-EwMdS3mrc3YBTLvgWRntPT0w5FWBNQlqTwsmSZHExvn490pOR/hIYV0T4rhicmqw7hs5mqFREYWVOb5wd2fKoA==", + "dependencies": { + "debug": "^4.1.1", + "serialport": "^10.4.0" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" + }, + "node_modules/node-cron": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.2.tgz", + "integrity": "sha512-iP8l0yGlNpE0e6q1o185yOApANRe47UPbLf4YxfbiNHt/RU5eBcGB/e0oudruheSf+LQeDMezqC5BVAb5wwRcQ==", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.0.tgz", + "integrity": "sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-sudo-nmap": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/node-sudo-nmap/-/node-sudo-nmap-4.0.4.tgz", + "integrity": "sha512-A6fciSibH7AEuOeIEHjY0IExWCmTV0Fvj5zIapCW4CuOqgdbECKPoIDgmZ2BTKyEWNYTNNiiCIUB1xbU8S+QwQ==", + "dependencies": { + "queued-up": "^2.0.2", + "xml2js": "^0.4.15" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/queued-up": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/queued-up/-/queued-up-2.0.2.tgz", + "integrity": "sha512-6ToqVyUPHRoIcxLKyUz7TCph2NULzoc41TAjdX/Fv7wsvj+E7tAAgqOab1cIFe0uTLJNWOswbLG4eDd2j3Y8AA==" + }, + "node_modules/sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "node_modules/serialport": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/serialport/-/serialport-10.5.0.tgz", + "integrity": "sha512-7OYLDsu5i6bbv3lU81pGy076xe0JwpK6b49G6RjNvGibstUqQkI+I3/X491yBGtf4gaqUdOgoU1/5KZ/XxL4dw==", + "dependencies": { + "@serialport/binding-mock": "10.2.2", + "@serialport/bindings-cpp": "10.8.0", + "@serialport/parser-byte-length": "10.5.0", + "@serialport/parser-cctalk": "10.5.0", + "@serialport/parser-delimiter": "10.5.0", + "@serialport/parser-inter-byte-timeout": "10.5.0", + "@serialport/parser-packet-length": "10.5.0", + "@serialport/parser-readline": "10.5.0", + "@serialport/parser-ready": "10.5.0", + "@serialport/parser-regex": "10.5.0", + "@serialport/parser-slip-encoder": "10.5.0", + "@serialport/parser-spacepacket": "10.5.0", + "@serialport/stream": "10.5.0", + "debug": "^4.3.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + } + } +} diff --git a/server/services/sunspec/package.json b/server/services/sunspec/package.json new file mode 100644 index 0000000000..03be55689d --- /dev/null +++ b/server/services/sunspec/package.json @@ -0,0 +1,23 @@ +{ + "name": "gladys-sunspec", + "version": "1.0.0", + "main": "index.js", + "os": [ + "darwin", + "linux", + "freebsd", + "win32" + ], + "cpu": [ + "x64", + "arm", + "arm64" + ], + "scripts": {}, + "dependencies": { + "axios": "^1.4.0", + "modbus-serial": "^8.0.11", + "node-cron": "^3.0.2", + "node-sudo-nmap": "^4.0.4" + } +} diff --git a/server/test/services/sunspec/api/sunspec.controller.test.js b/server/test/services/sunspec/api/sunspec.controller.test.js new file mode 100644 index 0000000000..febb611798 --- /dev/null +++ b/server/test/services/sunspec/api/sunspec.controller.test.js @@ -0,0 +1,151 @@ +const sinon = require('sinon'); + +const { assert, fake } = sinon; +const SunSpecController = require('../../../../services/sunspec/api/sunspec.controller'); + +const sunSpecManager = { + getDevices: fake.returns({}), + connect: fake.resolves(true), + scanNetwork: fake.resolves(true), + disconnect: fake.resolves(true), + getStatus: fake.returns({ + connected: true, + }), + getConfiguration: fake.returns({ sunspecUrl: 'sunspecUrl' }), + updateConfiguration: fake.resolves({ sunspecUrl: 'newSunspecUrl' }), + bdpvInit: fake.resolves(null), +}; + +describe('Devices API', () => { + let controller; + + beforeEach(() => { + controller = SunSpecController(sunSpecManager); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('getDevices', async () => { + const req = {}; + const res = { + json: fake.returns(null), + }; + + controller['get /api/v1/service/sunspec/discover'].controller(req, res); + assert.calledOnce(sunSpecManager.getDevices); + assert.calledOnceWithExactly(res.json, {}); + }); + + it('getDevices with query', async () => { + const req = { + query: { search: 'search' }, + }; + const res = { + json: fake.returns(null), + }; + + controller['get /api/v1/service/sunspec/discover'].controller(req, res); + assert.calledOnceWithExactly(sunSpecManager.getDevices, { search: 'search' }); + assert.calledOnceWithExactly(res.json, {}); + }); + + it('scan for devices', async () => { + const req = {}; + const res = { + json: fake.returns(null), + }; + + await controller['post /api/v1/service/sunspec/discover'].controller(req, res); + assert.calledOnceWithExactly(sunSpecManager.scanNetwork); + assert.calledOnceWithExactly(res.json, { success: true }); + }); +}); + +describe('Status API', () => { + let controller; + + beforeEach(() => { + controller = SunSpecController(sunSpecManager); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('connect', async () => { + const req = {}; + const res = { + json: fake.returns(null), + }; + + await controller['post /api/v1/service/sunspec/connect'].controller(req, res); + assert.calledOnceWithExactly(sunSpecManager.connect); + assert.calledOnceWithExactly(res.json, { success: true }); + }); + + it('disconnect', async () => { + const req = {}; + const res = { + json: fake.returns(null), + }; + + await controller['post /api/v1/service/sunspec/disconnect'].controller(req, res); + assert.calledOnceWithExactly(sunSpecManager.disconnect); + assert.calledOnceWithExactly(res.json, { success: true }); + }); + + it('get status', async () => { + const req = {}; + const res = { + json: fake.returns(null), + }; + + controller['get /api/v1/service/sunspec/status'].controller(req, res); + assert.calledOnceWithExactly(sunSpecManager.getStatus); + assert.calledOnce(res.json); + }); +}); + +describe('Configuration API', () => { + let controller; + + beforeEach(() => { + controller = SunSpecController(sunSpecManager); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('get configuration', async () => { + const req = {}; + const res = { + json: fake.returns(null), + }; + + await controller['get /api/v1/service/sunspec/config'].controller(req, res); + assert.calledOnce(sunSpecManager.getConfiguration); + assert.calledOnceWithExactly(res.json, { sunspecUrl: 'sunspecUrl' }); + }); + + it('update configuration', async () => { + const req = { + body: { + sunspecUrl: 'newSunspecUrl', + }, + }; + const res = { + json: fake.returns(null), + }; + sunSpecManager.getConfiguration = fake.returns({ sunspecUrl: 'newSunspecUrl' }); + + await controller['post /api/v1/service/sunspec/config'].controller(req, res); + assert.calledOnceWithExactly(sunSpecManager.updateConfiguration, { sunspecUrl: 'newSunspecUrl' }); + assert.calledOnce(sunSpecManager.disconnect); + assert.calledOnce(sunSpecManager.connect); + assert.calledOnce(sunSpecManager.getConfiguration); + assert.calledOnceWithExactly(res.json, { sunspecUrl: 'newSunspecUrl' }); + }); +}); diff --git a/server/test/services/sunspec/index.test.js b/server/test/services/sunspec/index.test.js new file mode 100644 index 0000000000..8680794180 --- /dev/null +++ b/server/test/services/sunspec/index.test.js @@ -0,0 +1,75 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; +const proxyquire = require('proxyquire').noCallThru(); +const ModbusTCPMock = require('./lib/utils/ModbusTCPMock.test'); + +class SunspecManagerMock { + connect() { + this.connected = true; + } + + disconnect() { + this.connected = false; + } + + // eslint-disable-next-line class-methods-use-this + bdpvInit() {} + + // eslint-disable-next-line class-methods-use-this + getConfiguration() { + return { + sunspecUrl: 'sunspecUrl', + }; + } +} + +const SunSpecService = proxyquire('../../../services/sunspec', { + 'modbus-serial': ModbusTCPMock, + './lib': SunspecManagerMock, +}); + +const gladys = { + variable: { + getValue: fake.resolves('localhost:502'), + }, +}; + +const SERVICE_ID = 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'; + +describe('SunSpecService', () => { + beforeEach(() => { + gladys.event = { + emit: fake.returns(true), + }; + }); + + afterEach(() => { + sinon.reset(); + }); + + const sunSpecService = SunSpecService(gladys, SERVICE_ID); + + it('should start service', async () => { + await sunSpecService.start(); + expect(sunSpecService.device.connected).eql(true); + }); + + it('should stop service', async () => { + await sunSpecService.stop(); + expect(sunSpecService.device.connected).eql(false); + }); + + it('should not be used service', async () => { + const isUsed = await sunSpecService.isUsed(); + expect(isUsed).eql(false); + }); + + it('should be used service', async () => { + sunSpecService.device.connected = true; + sunSpecService.device.devices = [{}]; + const isUsed = await sunSpecService.isUsed(); + expect(isUsed).eql(true); + }); +}); diff --git a/server/test/services/sunspec/lib/bdpv/sunspec.bdpv.test.js b/server/test/services/sunspec/lib/bdpv/sunspec.bdpv.test.js new file mode 100644 index 0000000000..6886a32670 --- /dev/null +++ b/server/test/services/sunspec/lib/bdpv/sunspec.bdpv.test.js @@ -0,0 +1,135 @@ +const { expect } = require('chai'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +const { fake, assert, stub } = sinon; + +describe('SunSpec BDPV', () => { + let gladys; + let sunSpecManager; + let MockedClient; + let MockedCron; + let Bdpv; + + beforeEach(() => { + const feature1 = { + type: 'energy', + last_value: 1234, + }; + const feature2 = { + type: 'energy', + last_value: 5678, + }; + gladys = { + event: { + emit: fake.resolves(null), + }, + stateManager: { + get: stub() + .onFirstCall() + .returns(feature1) + .onSecondCall() + .returns(feature2), + }, + variable: { + getValue: stub() + .onFirstCall() + .returns('SUNSPEC_BDPV_USER_NAME') + .onSecondCall() + .returns('SUNSPEC_BDPV_API_KEY'), + }, + }; + + sunSpecManager = { + gladys, + getDevices: () => { + return [ + { + external_id: 'sunspec:1:mppt:ac', + features: [feature1], + }, + { + external_id: 'sunspec:2:mppt:ac', + features: [feature2], + }, + ]; + }, + }; + + MockedClient = { + create: fake.returns({ + get: fake.returns({ + status: true, + }), + }), + }; + MockedCron = { + schedule: (expr, callback) => { + return { + start: fake.returns(null), + stop: fake.returns(null), + run: callback, + }; + }, + }; + + Bdpv = proxyquire('../../../../../services/sunspec/lib/bdpv/sunspec.bdpv', { + axios: MockedClient, + 'node-cron': MockedCron, + }); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should bdpvInit active', async () => { + await Bdpv.bdpvInit.call(sunSpecManager, true); + sunSpecManager.bdpvTask.run.call(sunSpecManager); + assert.calledTwice(gladys.variable.getValue); + assert.calledOnce(MockedClient.create); + // eslint-disable-next-line no-unused-expressions + expect(sunSpecManager.bdpvTask).to.be.not.null; + assert.calledOnce(sunSpecManager.bdpvTask.start); + assert.notCalled(sunSpecManager.bdpvTask.stop); + }); + + it('should bdpvInit not active', async () => { + await Bdpv.bdpvInit.call(sunSpecManager, false); + }); + + it('should bdpvInit disactivated', async () => { + sunSpecManager.bdpvTask = { + stop: fake.returns(null), + }; + await Bdpv.bdpvInit.call(sunSpecManager, false); + assert.calledOnce(sunSpecManager.bdpvTask.stop); + }); + + it('should bdpvPush', async () => { + sunSpecManager.bdpvParams = {}; + sunSpecManager.bdpvClient = { + get: fake.returns({ + status: true, + }), + }; + await Bdpv.bdpvPush.call(sunSpecManager); + assert.calledOnceWithExactly( + sunSpecManager.bdpvClient.get, + 'https://www.bdpv.fr/webservice/majProd/expeditionProd_v3.php', + { + params: { + index: 6912000, + }, + }, + ); + }); + + it('should bdpvPush error', async () => { + sunSpecManager.bdpvParams = {}; + sunSpecManager.bdpvClient = { + get: fake.throws(new Error('error')), + }; + await Bdpv.bdpvPush.call(sunSpecManager); + }); +}); diff --git a/server/test/services/sunspec/lib/sunspec.connect.test.js b/server/test/services/sunspec/lib/sunspec.connect.test.js new file mode 100644 index 0000000000..aab33bfc50 --- /dev/null +++ b/server/test/services/sunspec/lib/sunspec.connect.test.js @@ -0,0 +1,68 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake, assert } = sinon; + +const proxyquire = require('proxyquire'); + +const scanMock = fake.returns(['192.168.1.x']); +const scanNetworkMock = fake.returns(null); +const scanDevicesMock = fake.returns(null); +const ModbusTCPMock = require('./utils/ModbusTCPMock.test'); + +const SunSpecManager = proxyquire('../../../../services/sunspec/lib', { + ModbusTCP: { ModbusTCP: ModbusTCPMock }, + './sunspec.scan': { scan: scanMock }, + './sunspec.scanNetwork': { scanNetwork: scanNetworkMock }, + './sunspec.scanDevices': { scanDevices: scanDevicesMock }, +}); + +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); + +const SERVICE_ID = 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'; + +describe('SunSpec connect', () => { + // PREPARE + let gladys; + let sunSpecManager; + + beforeEach(() => { + gladys = { + variable: { + getValue: fake.resolves('sunspecUrl'), + }, + event: { + emit: fake.returns(null), + }, + }; + + sunSpecManager = new SunSpecManager(gladys, ModbusTCPMock, null, SERVICE_ID); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should connect', async () => { + await sunSpecManager.connect(); + + expect(sunSpecManager.connected).eql(true); + assert.calledOnce(sunSpecManager.scan); + assert.calledOnceWithExactly(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.CONNECTED, + }); + assert.calledOnce(sunSpecManager.scanNetwork); + }); + + it('should connect - error', async () => { + ModbusTCPMock.connectTCP = fake.throws('Error'); + await sunSpecManager.connect(); + + expect(sunSpecManager.connected).eql(true); + assert.calledOnce(sunSpecManager.scan); + assert.calledOnceWithExactly(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.CONNECTED, + }); + assert.calledOnce(sunSpecManager.scanNetwork); + }); +}); diff --git a/server/test/services/sunspec/lib/sunspec.disconnect.test.js b/server/test/services/sunspec/lib/sunspec.disconnect.test.js new file mode 100644 index 0000000000..31cc4e4ff7 --- /dev/null +++ b/server/test/services/sunspec/lib/sunspec.disconnect.test.js @@ -0,0 +1,57 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake, assert } = sinon; + +const proxyquire = require('proxyquire'); +const ModbusTCPMock = require('./utils/ModbusTCPMock.test'); +const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../../utils/constants'); + +const SunSpecManager = proxyquire('../../../../services/sunspec/lib', { + ModbusTCP: { ModbusTCP: ModbusTCPMock }, + modbus: ModbusTCPMock, +}); + +const SERVICE_ID = 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'; + +describe('SunSpec disconnect', () => { + // PREPARE + let gladys; + let sunSpecManager; + + beforeEach(() => { + gladys = { + event: { + emit: fake.returns(null), + }, + }; + + sunSpecManager = new SunSpecManager(gladys, ModbusTCPMock, null, SERVICE_ID); + sunSpecManager.modbus = { + close: fake.yields(null), + }; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should disconnect - not connected', async () => { + sunSpecManager.connected = false; + await sunSpecManager.disconnect(); + + assert.calledOnce(sunSpecManager.modbus.close); + expect(sunSpecManager.connected).eql(false); + }); + + it('should disconnect', async () => { + sunSpecManager.connected = true; + await sunSpecManager.disconnect(); + + assert.calledOnce(sunSpecManager.modbus.close); + assert.calledOnceWithExactly(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.STATUS_CHANGE, + }); + expect(sunSpecManager.connected).eql(false); + }); +}); diff --git a/server/test/services/sunspec/lib/sunspec.getConfiguration.test.js b/server/test/services/sunspec/lib/sunspec.getConfiguration.test.js new file mode 100644 index 0000000000..f3c2edb304 --- /dev/null +++ b/server/test/services/sunspec/lib/sunspec.getConfiguration.test.js @@ -0,0 +1,50 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { stub } = sinon; + +const SunSpecManager = require('../../../../services/sunspec/lib'); +const ModbusTCPMock = require('./utils/ModbusTCPMock.test'); + +const SERVICE_ID = 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'; + +describe('SunSpec getConfiguration', () => { + // PREPARE + let gladys; + let sunSpecManager; + + beforeEach(() => { + gladys = { + stateManager: { + event: {}, + }, + variable: {}, + }; + + sunSpecManager = new SunSpecManager(gladys, ModbusTCPMock, null, SERVICE_ID); + sunSpecManager.ipMasks = [ + { + ip: '192.168.1.0/24', + }, + ]; + }); + + it('get config from service', async () => { + gladys.variable.getValue = stub() + .onFirstCall() + .returns('0') + .onSecondCall() + .returns('bdpvUsername') + .onThirdCall() + .returns('bdpvApiKey'); + + const configuration = await sunSpecManager.getConfiguration(); + + expect(configuration).deep.eq({ + ipMasks: sunSpecManager.ipMasks, + bdpvActive: false, + bdpvUsername: 'bdpvUsername', + bdpvApiKey: 'bdpvApiKey', + }); + }); +}); diff --git a/server/test/services/sunspec/lib/sunspec.getDevices.test.js b/server/test/services/sunspec/lib/sunspec.getDevices.test.js new file mode 100644 index 0000000000..b2e9208974 --- /dev/null +++ b/server/test/services/sunspec/lib/sunspec.getDevices.test.js @@ -0,0 +1,276 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const proxyquire = require('proxyquire'); +const ModbusTCPMock = require('./utils/ModbusTCPMock.test'); + +const SunSpecManager = proxyquire('../../../../services/sunspec/lib', { + ModbusTCP: { ModbusTCP: ModbusTCPMock }, + modbus: ModbusTCPMock, +}); + +const SERVICE_ID = 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'; + +describe('SunSpec getDevices', () => { + // PREPARE + let gladys; + let sunSpecManager; + + beforeEach(() => { + gladys = { + variable: { + getValue: fake.resolves('sunspecUrl'), + }, + stateManager: { + event: {}, + }, + }; + + sunSpecManager = new SunSpecManager(gladys, ModbusTCPMock, null, SERVICE_ID); + }); + + it('should no device', async () => { + sunSpecManager.devices = []; + const devices = await sunSpecManager.getDevices(); + expect(devices.length).eql(0); + }); + + it('should device AC', async () => { + sunSpecManager.devices = [ + { + manufacturer: 'manufacturer', + product: 'product', + serialNumber: 'serialNumber', + swVersion: 'swVersion', + }, + ]; + const devices = await sunSpecManager.getDevices(); + expect(devices.length).eql(1); + expect(devices[0]).to.deep.equals({ + model: 'manufacturer product [AC]', + name: 'manufacturer product [AC]', + external_id: 'sunspec:serialnumber:serialNumber:mppt:ac', + poll_frequency: 60000, + selector: 'sunspec-serialnumber-serialnumber-mppt-ac', + service_id: 'faea9c35-759a-44d5-bcc9-2af1de37b8b4', + should_poll: true, + features: [ + { + category: 'pv', + external_id: 'sunspec:serialnumber:serialNumber:mppt:ac:property:ACA', + has_feedback: false, + last_value: 0, + max: 400, + min: 0, + name: 'manufacturer product [AC] - ACA', + read_only: true, + selector: 'sunspec-serialnumber-serialnumber-mppt-ac-property-aca', + type: 'current', + unit: 'ampere', + }, + { + category: 'pv', + external_id: 'sunspec:serialnumber:serialNumber:mppt:ac:property:ACV', + has_feedback: false, + last_value: 0, + max: 400, + min: 0, + name: 'manufacturer product [AC] - ACV', + read_only: true, + selector: 'sunspec-serialnumber-serialnumber-mppt-ac-property-acv', + type: 'voltage', + unit: 'volt', + }, + { + category: 'pv', + external_id: 'sunspec:serialnumber:serialNumber:mppt:ac:property:ACW', + has_feedback: false, + last_value: 0, + max: 10000, + min: 0, + name: 'manufacturer product [AC] - ACW', + read_only: true, + selector: 'sunspec-serialnumber-serialnumber-mppt-ac-property-acw', + type: 'power', + unit: 'volt-ampere', + }, + { + category: 'pv', + external_id: 'sunspec:serialnumber:serialNumber:mppt:ac:property:ACWH', + has_feedback: false, + last_value: 0, + max: 1000000, + min: 0, + name: 'manufacturer product [AC] - ACWH', + read_only: true, + selector: 'sunspec-serialnumber-serialnumber-mppt-ac-property-acwh', + type: 'energy', + unit: 'kilowatt-hour', + }, + ], + params: [ + { + name: 'MANUFACTURER', + value: 'manufacturer', + }, + { + name: 'PRODUCT', + value: 'product', + }, + { + name: 'SERIAL_NUMBER', + value: 'serialNumber', + }, + { + name: 'SW_VERSION', + value: 'swVersion', + }, + ], + }); + }); + + it('should device DC', async () => { + sunSpecManager.devices = [ + { + manufacturer: 'manufacturer', + product: 'product', + serialNumber: 'serialNumber', + swVersion: 'swVersion', + mppt: 1, + }, + ]; + const devices = await sunSpecManager.getDevices(); + expect(devices.length).eql(1); + expect(devices[0]).to.deep.equals({ + model: 'manufacturer product [DC 1]', + name: 'manufacturer product [DC 1]', + external_id: 'sunspec:serialnumber:serialNumber:mppt:dc1', + poll_frequency: 60000, + selector: 'sunspec-serialnumber-serialnumber-mppt-dc1', + service_id: 'faea9c35-759a-44d5-bcc9-2af1de37b8b4', + should_poll: true, + features: [ + { + category: 'pv', + external_id: 'sunspec:serialnumber:serialNumber:mppt:dc1:property:DCA', + has_feedback: false, + last_value: 0, + max: 400, + min: 0, + name: 'manufacturer product [DC 1] - DCA', + read_only: true, + selector: 'sunspec-serialnumber-serialnumber-mppt-dc1-property-dca', + type: 'current', + unit: 'ampere', + }, + { + category: 'pv', + external_id: 'sunspec:serialnumber:serialNumber:mppt:dc1:property:DCV', + has_feedback: false, + last_value: 0, + max: 400, + min: 0, + name: 'manufacturer product [DC 1] - DCV', + read_only: true, + selector: 'sunspec-serialnumber-serialnumber-mppt-dc1-property-dcv', + type: 'voltage', + unit: 'volt', + }, + { + category: 'pv', + external_id: 'sunspec:serialnumber:serialNumber:mppt:dc1:property:DCW', + has_feedback: false, + last_value: 0, + max: 10000, + min: 0, + name: 'manufacturer product [DC 1] - DCW', + read_only: true, + selector: 'sunspec-serialnumber-serialnumber-mppt-dc1-property-dcw', + type: 'power', + unit: 'watt', + }, + { + category: 'pv', + external_id: 'sunspec:serialnumber:serialNumber:mppt:dc1:property:DCWH', + has_feedback: false, + last_value: 0, + max: 1000000, + min: 0, + name: 'manufacturer product [DC 1] - DCWH', + read_only: true, + selector: 'sunspec-serialnumber-serialnumber-mppt-dc1-property-dcwh', + type: 'energy', + unit: 'kilowatt-hour', + }, + ], + params: [ + { + name: 'MANUFACTURER', + value: 'manufacturer', + }, + { + name: 'PRODUCT', + value: 'product', + }, + { + name: 'SERIAL_NUMBER', + value: 'serialNumber', + }, + { + name: 'SW_VERSION', + value: 'swVersion', + }, + ], + }); + }); + + it('should found device by manufacturer', async () => { + sunSpecManager.devices = [ + { + manufacturer: 'manufacturer', + product: 'product', + serialNumber: 'serialNumber', + swVersion: 'swVersion', + mppt: 1, + }, + ]; + const devices = await sunSpecManager.getDevices({ + search: 'manu', + }); + expect(devices.length).eql(1); + }); + + it('should found device by product', async () => { + sunSpecManager.devices = [ + { + manufacturer: 'manufacturer', + product: 'product', + serialNumber: 'serialNumber', + swVersion: 'swVersion', + mppt: 1, + }, + ]; + const devices = await sunSpecManager.getDevices({ + search: 'prod', + }); + expect(devices.length).eql(1); + }); + + it('should not found device', async () => { + sunSpecManager.devices = [ + { + manufacturer: 'manufacturer', + product: 'product', + serialNumber: 'serialNumber', + swVersion: 'swVersion', + mppt: 1, + }, + ]; + const devices = await sunSpecManager.getDevices({ + search: 'serial', + }); + expect(devices.length).eql(0); + }); +}); diff --git a/server/test/services/sunspec/lib/sunspec.getStatus.test.js b/server/test/services/sunspec/lib/sunspec.getStatus.test.js new file mode 100644 index 0000000000..c688196400 --- /dev/null +++ b/server/test/services/sunspec/lib/sunspec.getStatus.test.js @@ -0,0 +1,47 @@ +const { expect } = require('chai'); + +const proxyquire = require('proxyquire'); +const ModbusTCPMock = require('./utils/ModbusTCPMock.test'); + +const SunSpecManager = proxyquire('../../../../services/sunspec/lib', { + ModbusTCP: { ModbusTCP: ModbusTCPMock }, + modbus: ModbusTCPMock, +}); + +const SERVICE_ID = 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'; + +describe('SunSpec getStatus', () => { + // PREPARE + let gladys; + let sunSpecManager; + + beforeEach(() => { + gladys = { + stateManager: { + event: {}, + }, + }; + + sunSpecManager = new SunSpecManager(gladys, ModbusTCPMock, null, SERVICE_ID); + }); + + it('should connected', async () => { + sunSpecManager.connected = true; + sunSpecManager.sunspecIps = ['192.168.1.xx']; + const status = await sunSpecManager.getStatus(); + expect(status).to.deep.equals({ + connected: true, + sunspecIps: ['192.168.1.xx'], + }); + }); + + it('should not connected', async () => { + sunSpecManager.connected = false; + sunSpecManager.sunspecIps = ['192.168.1.xx']; + const status = await sunSpecManager.getStatus(); + expect(status).to.deep.equals({ + connected: false, + sunspecIps: ['192.168.1.xx'], + }); + }); +}); diff --git a/server/test/services/sunspec/lib/sunspec.poll.test.js b/server/test/services/sunspec/lib/sunspec.poll.test.js new file mode 100644 index 0000000000..90dc4b4d7d --- /dev/null +++ b/server/test/services/sunspec/lib/sunspec.poll.test.js @@ -0,0 +1,27 @@ +const sinon = require('sinon'); + +const proxyquire = require('proxyquire'); + +const { fake, assert } = sinon; + +const Poll = proxyquire('../../../../services/sunspec/lib/sunspec.poll', {}).poll; + +describe('SunSpec poll', () => { + // PREPARE + let sunSpecManager; + + beforeEach(() => { + sunSpecManager = { + scanDevices: fake.returns(null), + }; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should poll', async () => { + await Poll.call(sunSpecManager); + assert.callCount(sunSpecManager.scanDevices, 1); + }); +}); diff --git a/server/test/services/sunspec/lib/sunspec.scan.test.js b/server/test/services/sunspec/lib/sunspec.scan.test.js new file mode 100644 index 0000000000..b47eb5d6a0 --- /dev/null +++ b/server/test/services/sunspec/lib/sunspec.scan.test.js @@ -0,0 +1,73 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const proxyquire = require('proxyquire'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); +const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); + +const { fake, assert } = sinon; +const ModbusTCPMock = require('./utils/ModbusTCPMock.test'); +const ScannerClassMock = require('./utils/ScannerClassMock.test'); + +const SERVICE_ID = 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'; + +const SunSpecManager = proxyquire('../../../../services/sunspec/lib', { + ModbusTCP: { ModbusTCP: ModbusTCPMock }, + ScannerClass: { ScannerClass: ScannerClassMock }, +}); + +describe('SunSpec scan', () => { + // PREPARE + let gladys; + let sunSpecManager; + + beforeEach(() => { + gladys = { + event: { + emit: fake.returns(null), + }, + variable: { + getValue: fake.resolves(null), + }, + }; + sunSpecManager = new SunSpecManager(gladys, ModbusTCPMock, ScannerClassMock, SERVICE_ID); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should not scan - not configured', async () => { + try { + await sunSpecManager.scan(); + assert.fail(); + } catch (error) { + expect(error).to.be.an.instanceof(ServiceNotConfiguredError); + } + }); + + it('should not scan - already scanning', async () => { + sunSpecManager.scanning = true; + await sunSpecManager.scan(); + assert.notCalled(gladys.variable.getValue); + }); + + it('should find Sunspec device', async () => { + gladys.variable.getValue = fake.resolves('[{"ip":"192.168.1.0/24"}]'); + await sunSpecManager.scan(); + assert.callCount(gladys.event.emit, 3); + assert.calledWithExactly(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.SCANNING, + payload: { + scanning: true, + }, + }); + assert.calledWithExactly(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.SCANNING, + payload: { + scanning: false, + success: true, + }, + }); + }); +}); diff --git a/server/test/services/sunspec/lib/sunspec.scanDevices.test.js b/server/test/services/sunspec/lib/sunspec.scanDevices.test.js new file mode 100644 index 0000000000..62ff7eb360 --- /dev/null +++ b/server/test/services/sunspec/lib/sunspec.scanDevices.test.js @@ -0,0 +1,221 @@ +const sinon = require('sinon'); + +const proxyquire = require('proxyquire'); +const { EVENTS } = require('../../../../utils/constants'); + +const { fake, assert, stub } = sinon; + +const ScanDevices = proxyquire('../../../../services/sunspec/lib/sunspec.scanDevices', { + './utils/sunspec.ModelFactory': {}, +}).scanDevices; + +describe('SunSpec scanDevices', () => { + // PREPARE + let gladys; + let modbus; + let sunSpecManager; + + beforeEach(() => { + gladys = { + stateManager: { + get: fake.resolves({}), + }, + event: { + emit: fake.returns(null), + }, + }; + + modbus = { + readModel: fake.throws(new Error('Model must be defined')), + }; + + sunSpecManager = { + gladys, + modbuses: [modbus], + devices: [], + }; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should find device AC', async () => { + sunSpecManager.devices.push({ + manufacturer: 'manufacturer', + product: 'product', + serialNumber: 'serialNumber', + swVersion: 'swVersion', + modbus, + }); + sunSpecManager.modbuses[0].readModel = stub() + .onFirstCall() + .returns({ + readUInt16BE: stub() + .onFirstCall() + .returns(101) + .onSecondCall() // ACA + .returns(1) + .onThirdCall() // ACV + .returns(2), + readInt16BE: stub() + .onFirstCall() // acaSf + .returns(1) + .onSecondCall() // acvSf + .returns(2) + .onThirdCall() // acwSf + .returns(3) + .onCall(3) // ACW + .returns(4) + .onCall(4) // acwhSf + .returns(5), + readUInt32BE: stub() + .onFirstCall() + .returns(1) + .onSecondCall() // ACWH + .returns(2), + }); + await ScanDevices.call(sunSpecManager); + assert.callCount(gladys.event.emit, 4); + assert.calledWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'sunspec:serialnumber:serialNumber:mppt:ac:property:ACA', + state: '10.00', + }); + assert.calledWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'sunspec:serialnumber:serialNumber:mppt:ac:property:ACV', + state: '200', + }); + assert.calledWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'sunspec:serialnumber:serialNumber:mppt:ac:property:ACW', + state: '4000', + }); + assert.calledWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'sunspec:serialnumber:serialNumber:mppt:ac:property:ACWH', + state: '100', + }); + }); + + it('should find device 3-AC', async () => { + sunSpecManager.devices.push({ + manufacturer: 'manufacturer', + product: 'product', + serialNumber: 'serialNumber', + swVersion: 'swVersion', + modbus, + }); + sunSpecManager.modbuses[0].readModel = stub() + .onFirstCall() + .returns({ + readUInt16BE: stub() + .onFirstCall() + .returns(103) + .onSecondCall() // ACA + .returns(1) + .onThirdCall() // acvA + .returns(2) + .onCall(3) // acvB + .returns(3) + .onCall(4) // acvC + .returns(4), + readInt16BE: stub() + .onFirstCall() // acaSf + .returns(1) + .onSecondCall() // acvSf + .returns(2) + .onThirdCall() // acwSf + .returns(3) + .onCall(3) // ACW + .returns(4) + .onCall(4) // acwhSf + .returns(5), + readUInt32BE: stub() + .onFirstCall() + .returns(1) + .onSecondCall() // ACWH + .returns(2), + }); + await ScanDevices.call(sunSpecManager); + assert.callCount(gladys.event.emit, 4); + assert.calledWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'sunspec:serialnumber:serialNumber:mppt:ac:property:ACA', + state: '10.00', + }); + assert.calledWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'sunspec:serialnumber:serialNumber:mppt:ac:property:ACV', + state: '300', + }); + assert.calledWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'sunspec:serialnumber:serialNumber:mppt:ac:property:ACW', + state: '4000', + }); + assert.calledWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'sunspec:serialnumber:serialNumber:mppt:ac:property:ACWH', + state: '100', + }); + }); + + it('should find device DC', async () => { + sunSpecManager.devices.push({ + manufacturer: 'manufacturer', + product: 'product', + serialNumber: 'serialNumber', + swVersion: 'swVersion', + mppt: 1, + modbus, + }); + sunSpecManager.modbuses[0].readModel = stub() + .onFirstCall() + .returns({ + readUInt16BE: stub() + .onFirstCall() + .returns(160) + .onSecondCall() // MPPT + .returns(1) + .onCall(3) // ID + .returns(1) + .onCall(4) // DCA + .returns(1) + .onCall(5) // DCV + .returns(1) + .onCall(6) // DCW + .returns(1), + readInt16BE: stub() + .onFirstCall() + .returns(1) + .onSecondCall() + .returns(2) + .onThirdCall() + .returns(3) + .onCall(3) + .returns(4) + .onCall(4) + .returns(0) + .onCall(5) + .returns(0), + readUInt32BE: stub() + .onFirstCall() + .returns(1) + .onSecondCall() // DCWH + .returns(2), + subarray: fake.returns('IDStr'), + }); + await ScanDevices.call(sunSpecManager); + assert.callCount(gladys.event.emit, 4); + assert.calledWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'sunspec:serialnumber:serialNumber:mppt:dc1:property:DCA', + state: '10.00', + }); + assert.calledWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'sunspec:serialnumber:serialNumber:mppt:dc1:property:DCV', + state: '100', + }); + assert.calledWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'sunspec:serialnumber:serialNumber:mppt:dc1:property:DCW', + state: '1000', + }); + assert.calledWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'sunspec:serialnumber:serialNumber:mppt:dc1:property:DCWH', + state: '20', + }); + }); +}); diff --git a/server/test/services/sunspec/lib/sunspec.scanNetwork.test.js b/server/test/services/sunspec/lib/sunspec.scanNetwork.test.js new file mode 100644 index 0000000000..2da9541d1c --- /dev/null +++ b/server/test/services/sunspec/lib/sunspec.scanNetwork.test.js @@ -0,0 +1,82 @@ +const sinon = require('sinon'); + +const { expect } = require('chai'); +const proxyquire = require('proxyquire'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); + +const { fake, assert, stub } = sinon; + +const ScanNetwork = proxyquire('../../../../services/sunspec/lib/sunspec.scanNetwork', { + './utils/sunspec.ModelFactory': {}, +}).scanNetwork; + +describe('SunSpec scanNetwork', () => { + // PREPARE + let gladys; + let modbus; + let sunSpecManager; + + beforeEach(() => { + gladys = { + event: { + emit: fake.returns(null), + }, + }; + + modbus = { + readModel: stub() + .onFirstCall() + .returns({ + readUInt16BE: stub() + .onFirstCall() + .returns(1), + subarray: stub() + .onFirstCall() + .returns('manufacturer') + .onSecondCall() + .returns('product') + .onThirdCall() + .returns('options') + .onCall(3) + .returns('swVersion') + .onCall(4) + .returns('serialNumber'), + }), + readRegisterAsInt16: fake.returns(1), + getValueModel: fake.returns(201), + }; + + sunSpecManager = { + gladys, + modbuses: [modbus], + }; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should find a AC and a DC device', async () => { + await ScanNetwork.call(sunSpecManager); + expect(sunSpecManager.devices.length).eql(2); + expect(sunSpecManager.devices[0]).to.deep.eq({ + modbus, + manufacturer: 'manufacturer', + product: 'product', + serialNumber: 'serialNumber', + swVersion: 'swVersion', + valueModel: 201, + }); + expect(sunSpecManager.devices[1]).to.deep.eq({ + modbus, + manufacturer: 'manufacturer', + product: 'product', + serialNumber: 'serialNumber', + swVersion: 'swVersion', + mppt: 1, + }); + assert.calledWithExactly(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.STATUS_CHANGE, + }); + }); +}); diff --git a/server/test/services/sunspec/lib/sunspec.updateConfiguration.test.js b/server/test/services/sunspec/lib/sunspec.updateConfiguration.test.js new file mode 100644 index 0000000000..37dea0cafe --- /dev/null +++ b/server/test/services/sunspec/lib/sunspec.updateConfiguration.test.js @@ -0,0 +1,76 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; + +const { CONFIGURATION } = require('../../../../services/sunspec/lib/sunspec.constants'); +const ModbusTCPMock = require('./utils/ModbusTCPMock.test'); +const SunSpecManager = require('../../../../services/sunspec/lib'); + +const SERVICE_ID = 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'; + +describe('SunSpec updateConfiguration', () => { + // PREPARE + let gladys; + let sunSpecManager; + + beforeEach(() => { + gladys = { + stateManager: { + event: {}, + }, + variable: { + setValue: fake.resolves('setValue'), + }, + }; + + sunSpecManager = new SunSpecManager(gladys, ModbusTCPMock, null, SERVICE_ID); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('not config param', async () => { + const configuration = undefined; + await sunSpecManager.updateConfiguration(configuration); + assert.notCalled(gladys.variable.setValue); + }); + + it('all config param', async () => { + const configuration = { + ipMasks: [ + { + ip: '192.168.1.0/24', + }, + ], + bdpvActive: 'bdpvActive', + bdpvUsername: 'bdpvUsername', + bdpvApiKey: 'bdpvApiKey', + }; + await sunSpecManager.updateConfiguration(configuration); + assert.callCount(gladys.variable.setValue, 4); + }); + + it('empty ipMasks config param', async () => { + const configuration = {}; + await sunSpecManager.updateConfiguration(configuration); + assert.notCalled(gladys.variable.setValue); + }); + + it('only ipMasks config param', async () => { + const configuration = { + ipMasks: [ + { + ip: '192.168.1.0/24', + }, + ], + }; + await sunSpecManager.updateConfiguration(configuration); + assert.calledOnceWithExactly( + gladys.variable.setValue, + CONFIGURATION.SUNSPEC_IP_MASKS, + '[{"ip":"192.168.1.0/24"}]', + SERVICE_ID, + ); + }); +}); diff --git a/server/test/services/sunspec/lib/utils/ModbusTCPMock.test.js b/server/test/services/sunspec/lib/utils/ModbusTCPMock.test.js new file mode 100644 index 0000000000..d1f145fa2a --- /dev/null +++ b/server/test/services/sunspec/lib/utils/ModbusTCPMock.test.js @@ -0,0 +1,56 @@ +const { REGISTER, MODEL } = require('../../../../../services/sunspec/lib/sunspec.constants'); + +class ModbusTCPMock { + // eslint-disable-next-line class-methods-use-this + connectTCP(host, options) {} + + readHoldingRegisters(registerId, length, callback) { + let data; + if (registerId === REGISTER.SID - 1) { + data = [21365, 28243]; + } else if (registerId === REGISTER.MODEL_ID - 1) { + data = [1]; + } else if (registerId === REGISTER.MODEL_ID) { + data = [0]; + } else if (registerId === REGISTER.MANUFACTURER - 1) { + data = [0xffff]; + } + callback.call(this, null, { data }); + } + + // eslint-disable-next-line class-methods-use-this + readModel(modelId) { + if (modelId === MODEL.COMMON) { + const arr = new Uint8Array(132); + arr[1] = 1; + return Buffer.from(arr.buffer); + } + if (modelId === MODEL.MPPT_INVERTER_EXTENSION) { + const arr = new Uint8Array(94); + arr[1] = 160; + + arr[5] = 0; // UInt16 + arr[7] = 1; // UInt16 + arr[9] = 2; // UInt16 + arr[11] = 3; // UInt16 + + arr[39] = 10; // UInt16 + arr[41] = 11; // UInt16 + arr[43] = 12; // UInt16 + arr[47] = 13; // UInt32 + + return Buffer.from(arr.buffer); + } + return null; + } + + // eslint-disable-next-line class-methods-use-this + readRegisterAsInt16(registerId) { + return 1; + } + + // eslint-disable-next-line class-methods-use-this + close() {} +} + +module.exports = ModbusTCPMock; diff --git a/server/test/services/sunspec/lib/utils/ScannerClassMock.test.js b/server/test/services/sunspec/lib/utils/ScannerClassMock.test.js new file mode 100644 index 0000000000..c4bf7b690c --- /dev/null +++ b/server/test/services/sunspec/lib/utils/ScannerClassMock.test.js @@ -0,0 +1,13 @@ +class ScannerClassMock { + // eslint-disable-next-line class-methods-use-this + on(step, cb) { + cb([ + { + ip: '192.168.1.xx', + openPorts: [502], + }, + ]); + } +} + +module.exports = ScannerClassMock; diff --git a/server/test/services/sunspec/lib/utils/sunspec.ModbusClient.test.js b/server/test/services/sunspec/lib/utils/sunspec.ModbusClient.test.js new file mode 100644 index 0000000000..1363605eaa --- /dev/null +++ b/server/test/services/sunspec/lib/utils/sunspec.ModbusClient.test.js @@ -0,0 +1,186 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { fake, assert, stub } = sinon; + +const { ModbusClient } = require('../../../../../services/sunspec/lib/utils/sunspec.ModbusClient'); +const { MODEL } = require('../../../../../services/sunspec/lib/sunspec.constants'); + +describe('SunSpec ModbusClient', () => { + let modbusClientApi; + + beforeEach(() => { + modbusClientApi = { + connectTCP: fake.returns(true), + close: stub().yields(), + readHoldingRegisters: fake.returns(true), + }; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should not connect - wrong SID', async () => { + const client = new ModbusClient(modbusClientApi); + client.readRegisterAsInt32 = stub() + .onFirstCall() + .returns(0x00); + await client.connect('host', 502); + assert.calledOnce(modbusClientApi.connectTCP); + expect(client.models).to.deep.equal({}); + }); + + it('should not connect - missing model', async () => { + const client = new ModbusClient(modbusClientApi); + client.readRegisterAsInt32 = stub() + .onFirstCall() + .returns(0x53756e53); + client.readRegisterAsInt16 = stub() + .onCall(0) // MODEL_ID + .returns(2); + await client.connect('host', 502); + assert.calledOnce(modbusClientApi.connectTCP); + expect(client.models).to.deep.equal({}); + }); + + it('should connect', async () => { + const client = new ModbusClient(modbusClientApi); + client.readRegisterAsInt32 = stub() + .onFirstCall() + .returns(0x53756e53); + client.readRegisterAsInt16 = stub() + .onCall(0) // MODEL_ID + .returns(1) + .onCall(1) + .returns(5) // MODEL_ID length + .onCall(2) + .returns(45) // Next + .onCall(3) + .returns(1234) // Next MODEL_ID + .onCall(4) + .returns(1234) // Next MODEL_ID length + .onCall(5) + .returns(0xffff); // End + await client.connect('host', 502); + assert.calledOnce(modbusClientApi.connectTCP); + expect(client.models).to.deep.equal({ + 1: { + registerStart: 40003, + registerLength: 5, + }, + 1234: { + registerStart: 40050, + registerLength: 1234, + }, + }); + }); + + it('should connect - error read', async () => { + const client = new ModbusClient(modbusClientApi); + client.readRegisterAsInt32 = stub() + .onFirstCall() + .throws(new Error('Error read')); + try { + await client.connect('host', 502); + assert.fail(); + } catch (e) { + expect(e).to.be.instanceOf(Error); + expect(e.message).to.be.equals('Unable to connect Sunspec device host:502 - Error: Error read'); + } + }); + + it('should connect - error readRegister', async () => { + modbusClientApi.readHoldingRegisters = stub().yields(new Error('readRegister')); + const client = new ModbusClient(modbusClientApi); + try { + await client.readRegister(0, 0); + assert.fail(); + } catch (e) { + expect(e).to.be.instanceOf(Error); + expect(e.message).to.be.equals('readRegister'); + } + }); + + it('should connect - error readRegisterAsInt16', async () => { + modbusClientApi.readHoldingRegisters = stub().yields(new Error('readRegisterAsInt16')); + const client = new ModbusClient(modbusClientApi); + try { + await client.readRegisterAsInt16(0); + assert.fail(); + } catch (e) { + expect(e).to.be.instanceOf(Error); + expect(e.message).to.be.equals('readRegisterAsInt16'); + } + }); + + it('should connect - error readRegisterAsInt32', async () => { + modbusClientApi.readHoldingRegisters = stub().yields(new Error('readRegisterAsInt32')); + const client = new ModbusClient(modbusClientApi); + try { + await client.readRegisterAsInt32(0); + assert.fail(); + } catch (e) { + expect(e).to.be.instanceOf(Error); + expect(e.message).to.be.equals('readRegisterAsInt32'); + } + }); + + it('should close', async () => { + const client = new ModbusClient(modbusClientApi); + await client.close(); + assert.calledOnce(modbusClientApi.close); + }); + + it('should readModel', async () => { + const client = new ModbusClient(modbusClientApi); + modbusClientApi.readHoldingRegisters = stub().yields(null, { + buffer: Buffer.from([0, 1]), + }); + client.models = { + 101: { + registerStart: 0, + registerLength: 10, + }, + }; + const res = await client.readModel(101); + expect(res).to.be.instanceof(Buffer); + expect(res).to.deep.equal(Buffer.from([0, 1])); + }); + + it('should readRegister', async () => { + const client = new ModbusClient(modbusClientApi); + modbusClientApi.readHoldingRegisters = stub().yields(null, { + buffer: Buffer.from([0, 1]), + }); + const res = await client.readRegister(1, 4); + expect(res).to.be.instanceof(Buffer); + expect(res).to.deep.equal(Buffer.from([0, 1])); + }); + + it('should readRegisterAsString', async () => { + const client = new ModbusClient(modbusClientApi); + modbusClientApi.readHoldingRegisters = stub().yields(null, { + buffer: Buffer.from('1234'), + }); + const res = await client.readRegisterAsString(1, 4); + expect(res).to.eq('1234'); + }); + + it('should getValueModel 1-phase', async () => { + const client = new ModbusClient(modbusClientApi); + client.models[MODEL.INVERTER_1_PHASE] = {}; + expect(client.getValueModel()).to.eq(101); + }); + + it('should getValueModel split-phase', async () => { + const client = new ModbusClient(modbusClientApi); + expect(client.getValueModel()).to.eq(102); + }); + + it('should getValueModel 3-phase', async () => { + const client = new ModbusClient(modbusClientApi); + client.models[MODEL.INVERTER_3_PHASE] = {}; + expect(client.getValueModel()).to.eq(103); + }); +}); diff --git a/server/test/services/sunspec/lib/utils/sunspec.externalId.test.js b/server/test/services/sunspec/lib/utils/sunspec.externalId.test.js new file mode 100644 index 0000000000..6b1a265873 --- /dev/null +++ b/server/test/services/sunspec/lib/utils/sunspec.externalId.test.js @@ -0,0 +1,80 @@ +const { expect } = require('chai'); + +const { + getDeviceName, + getDeviceExternalId, + getDeviceFeatureName, + getDeviceFeatureExternalId, +} = require('../../../../../services/sunspec/lib/utils/sunspec.externalId'); + +describe('SunSpec getStatus', () => { + const manufacturer = 'manufacturer'; + const product = 'product'; + const serialNumber = 'serialNumber'; + const property = 'property'; + + it('should getDeviceName AC', () => { + const name = getDeviceName({ + manufacturer, + product, + }); + expect(name).to.be.equals('manufacturer product [AC]'); + }); + + it('should getDeviceName DC', () => { + const name = getDeviceName({ + manufacturer, + product, + mppt: 1, + }); + expect(name).to.be.equals('manufacturer product [DC 1]'); + }); + + it('should getDeviceExternalId AC', () => { + const externalId = getDeviceExternalId({ + manufacturer, + product, + serialNumber, + }); + expect(externalId).to.be.equals('sunspec:serialnumber:serialNumber:mppt:ac'); + }); + + it('should getDeviceExternalId DC', () => { + const externalId = getDeviceExternalId({ + manufacturer, + product, + serialNumber, + mppt: 1, + }); + expect(externalId).to.be.equals('sunspec:serialnumber:serialNumber:mppt:dc1'); + }); + + it('should getDeviceFeatureName AC', () => { + const name = getDeviceFeatureName({ + manufacturer, + product, + property, + }); + expect(name).to.be.equals('manufacturer product [AC] - property'); + }); + + it('should getDeviceFeatureName DC', () => { + const name = getDeviceFeatureName({ + manufacturer, + product, + mppt: 1, + property, + }); + expect(name).to.be.equals('manufacturer product [DC 1] - property'); + }); + + it('should getDeviceFeatureExternalId', () => { + const externalId = getDeviceFeatureExternalId({ + manufacturer, + product, + serialNumber, + property, + }); + expect(externalId).to.be.equals('sunspec:serialnumber:serialNumber:mppt:ac:property:property'); + }); +}); diff --git a/server/utils/constants.js b/server/utils/constants.js index 09a07b77c0..03a590836d 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -422,6 +422,7 @@ const DEVICE_FEATURE_CATEGORIES = { VIBRATION_SENSOR: 'vibration-sensor', VOC_SENSOR: 'voc-sensor', VOLUME_SENSOR: 'volume-sensor', + PV: 'pv', TEXT: 'text', }; @@ -581,6 +582,12 @@ const DEVICE_FEATURE_TYPES = { AIRQUALITY_SENSOR: { AQI: 'aqi', }, + PV: { + POWER: 'power', + ENERGY: 'energy', + VOLTAGE: 'voltage', + CURRENT: 'current', + }, TEXT: { TEXT: 'text', }, @@ -810,6 +817,9 @@ const ACTIONS_STATUS = { }; const DEVICE_POLL_FREQUENCIES = { + EVERY_30_MINUTES: 30 * 60 * 1000, + EVERY_10_MINUTES: 10 * 60 * 1000, + EVERY_2_MINUTES: 2 * 60 * 1000, EVERY_MINUTES: 60 * 1000, EVERY_30_SECONDS: 30 * 1000, EVERY_10_SECONDS: 10 * 1000, @@ -905,6 +915,11 @@ const WEBSOCKET_MESSAGE_TYPES = { LEARN_MODE: 'broadlink.learn', SEND_MODE: 'broadlink.send', }, + SUNSPEC: { + CONNECTED: 'sunspec.connected', + STATUS_CHANGE: 'sunspec.status-change', + SCANNING: 'sunspec.scanning', + }, TUYA: { STATUS: 'tuya.status', DISCOVER: 'tuya.discover',