From d6dfdc914c6bb4ba564207e8cedf46d58f5def3d Mon Sep 17 00:00:00 2001 From: Thomas LEMAISTRE <35010958+Terdious@users.noreply.github.com> Date: Tue, 30 Jan 2024 08:27:26 +0100 Subject: [PATCH] Netatmo integration (#1951) --- .../src/assets/integrations/cover/netatmo.jpg | Bin 0 -> 29655 bytes .../devices/netatmo/netatmo-NAPlug.jpg | Bin 0 -> 22640 bytes .../devices/netatmo/netatmo-NATherm1.jpg | Bin 0 -> 27078 bytes front/src/components/app.jsx | 9 + front/src/config/i18n/de.json | 76 ++++ front/src/config/i18n/en.json | 76 ++++ front/src/config/i18n/fr.json | 76 ++++ front/src/config/integrations/devices.json | 5 + .../all/netatmo/NetatmoDeviceBox.jsx | 390 ++++++++++++++++++ .../integration/all/netatmo/NetatmoPage.jsx | 72 ++++ .../all/netatmo/device-page/DeviceTab.jsx | 141 +++++++ .../all/netatmo/device-page/EmptyState.jsx | 23 ++ .../netatmo/device-page/StateConnection.jsx | 45 ++ .../all/netatmo/device-page/index.js | 137 ++++++ .../all/netatmo/device-page/style.css | 7 + .../all/netatmo/discover-page/DiscoverTab.jsx | 135 ++++++ .../all/netatmo/discover-page/EmptyState.jsx | 13 + .../netatmo/discover-page/StateConnection.jsx | 49 +++ .../all/netatmo/discover-page/index.js | 133 ++++++ .../all/netatmo/discover-page/style.css | 7 + .../all/netatmo/setup-page/SetupTab.jsx | 171 ++++++++ .../netatmo/setup-page/StateConnection.jsx | 40 ++ .../all/netatmo/setup-page/index.js | 305 ++++++++++++++ .../all/netatmo/setup-page/style.css | 30 ++ .../routes/integration/all/netatmo/style.css | 5 + front/src/utils/consts.js | 2 + server/services/index.js | 1 + .../netatmo/api/netatmo.controller.js | 134 ++++++ server/services/netatmo/index.js | 50 +++ .../lib/device/netatmo.buildFeaturesCommon.js | 33 ++ .../device/netatmo.buildFeaturesCommonTemp.js | 58 +++ .../lib/device/netatmo.buildFeaturesSignal.js | 54 +++ .../netatmo.buildFeaturesSpecifEnergy.js | 81 ++++ .../lib/device/netatmo.convertDevice.js | 84 ++++ .../lib/device/netatmo.deviceMapping.js | 56 +++ .../lib/device/netatmo.updateNAPlug.js | 53 +++ .../lib/device/netatmo.updateNATherm1.js | 78 ++++ server/services/netatmo/lib/index.js | 70 ++++ .../services/netatmo/lib/netatmo.connect.js | 34 ++ .../netatmo/lib/netatmo.disconnect.js | 24 ++ .../netatmo/lib/netatmo.discoverDevices.js | 49 +++ .../netatmo/lib/netatmo.getAccessToken.js | 36 ++ .../netatmo/lib/netatmo.getConfiguration.js | 27 ++ .../netatmo/lib/netatmo.getRefreshToken.js | 37 ++ .../services/netatmo/lib/netatmo.getStatus.js | 18 + server/services/netatmo/lib/netatmo.init.js | 19 + .../netatmo/lib/netatmo.loadDeviceDetails.js | 139 +++++++ .../netatmo/lib/netatmo.loadDevices.js | 47 +++ .../lib/netatmo.loadThermostatDetails.js | 43 ++ .../lib/netatmo.pollRefreshingToken.js | 29 ++ .../lib/netatmo.pollRefreshingValues.js | 50 +++ .../netatmo/lib/netatmo.refreshingTokens.js | 66 +++ .../netatmo/lib/netatmo.retrieveTokens.js | 73 ++++ .../netatmo/lib/netatmo.saveConfiguration.js | 30 ++ .../netatmo/lib/netatmo.saveStatus.js | 113 +++++ .../services/netatmo/lib/netatmo.setTokens.js | 32 ++ .../services/netatmo/lib/netatmo.setValue.js | 76 ++++ .../netatmo/lib/netatmo.updateValues.js | 35 ++ .../lib/utils/netatmo.buildScopesConfig.js | 26 ++ .../netatmo/lib/utils/netatmo.constants.js | 95 +++++ server/services/netatmo/package-lock.json | 121 ++++++ server/services/netatmo/package.json | 19 + .../controllers/netatmo.controller.test.js | 132 ++++++ server/test/services/netatmo/index.test.js | 58 +++ .../lib/device/netatmo.convertDevice.test.js | 115 ++++++ .../lib/device/netatmo.deviceMapping.test.js | 75 ++++ .../lib/device/netatmo.updateNAPlug.test.js | 114 +++++ .../lib/device/netatmo.updateNATherm1.test.js | 108 +++++ .../test/services/netatmo/lib/index.test.js | 23 ++ .../netatmo/lib/netatmo.connect.test.js | 49 +++ .../netatmo/lib/netatmo.disconnect.test.js | 59 +++ .../lib/netatmo.discoverDevices.test.js | 103 +++++ .../lib/netatmo.getAccessToken.test.js | 71 ++++ .../lib/netatmo.getConfiguration.test.js | 65 +++ .../lib/netatmo.getRefreshToken.test.js | 89 ++++ .../netatmo/lib/netatmo.getStatus.test.js | 34 ++ .../services/netatmo/lib/netatmo.init.test.js | 90 ++++ .../lib/netatmo.loadDeviceDetails.test.js | 153 +++++++ .../netatmo/lib/netatmo.loadDevices.test.js | 129 ++++++ .../lib/netatmo.loadThermostatDetails.test.js | 86 ++++ .../lib/netatmo.pollRefreshingTokens.test.js | 137 ++++++ .../lib/netatmo.pollRefreshingValues.test.js | 120 ++++++ .../lib/netatmo.refreshingTokens.test.js | 183 ++++++++ .../netatmo/lib/netatmo.retrieveToken.test.js | 172 ++++++++ .../lib/netatmo.saveConfiguration.test.js | 73 ++++ .../netatmo/lib/netatmo.saveStatus.test.js | 298 +++++++++++++ .../netatmo/lib/netatmo.setTokens.test.js | 89 ++++ .../netatmo/lib/netatmo.setValue.test.js | 180 ++++++++ .../netatmo/lib/netatmo.updateValues.test.js | 96 +++++ .../netatmo.convertDevices.mock.test.json | 257 ++++++++++++ .../netatmo.discoverDevices.mock.test.json | 106 +++++ .../netatmo.getThermostat.mock.test.json | 63 +++ .../netatmo/netatmo.homesdata.mock.test.json | 127 ++++++ .../netatmo/netatmo.homestatus.mock.test.json | 90 ++++ .../netatmo.loadDevices.mock.test.json | 174 ++++++++ .../netatmo.loadDevicesDetails.mock.test.json | 190 +++++++++ ...tatmo.loadThermostatDetails.mock.test.json | 80 ++++ .../services/netatmo/netatmo.mock.test.js | 64 +++ server/utils/constants.js | 8 + 99 files changed, 7997 insertions(+) create mode 100644 front/src/assets/integrations/cover/netatmo.jpg create mode 100644 front/src/assets/integrations/devices/netatmo/netatmo-NAPlug.jpg create mode 100644 front/src/assets/integrations/devices/netatmo/netatmo-NATherm1.jpg create mode 100644 front/src/routes/integration/all/netatmo/NetatmoDeviceBox.jsx create mode 100644 front/src/routes/integration/all/netatmo/NetatmoPage.jsx create mode 100644 front/src/routes/integration/all/netatmo/device-page/DeviceTab.jsx create mode 100644 front/src/routes/integration/all/netatmo/device-page/EmptyState.jsx create mode 100644 front/src/routes/integration/all/netatmo/device-page/StateConnection.jsx create mode 100644 front/src/routes/integration/all/netatmo/device-page/index.js create mode 100644 front/src/routes/integration/all/netatmo/device-page/style.css create mode 100644 front/src/routes/integration/all/netatmo/discover-page/DiscoverTab.jsx create mode 100644 front/src/routes/integration/all/netatmo/discover-page/EmptyState.jsx create mode 100644 front/src/routes/integration/all/netatmo/discover-page/StateConnection.jsx create mode 100644 front/src/routes/integration/all/netatmo/discover-page/index.js create mode 100644 front/src/routes/integration/all/netatmo/discover-page/style.css create mode 100644 front/src/routes/integration/all/netatmo/setup-page/SetupTab.jsx create mode 100644 front/src/routes/integration/all/netatmo/setup-page/StateConnection.jsx create mode 100644 front/src/routes/integration/all/netatmo/setup-page/index.js create mode 100644 front/src/routes/integration/all/netatmo/setup-page/style.css create mode 100644 front/src/routes/integration/all/netatmo/style.css create mode 100644 server/services/netatmo/api/netatmo.controller.js create mode 100644 server/services/netatmo/index.js create mode 100644 server/services/netatmo/lib/device/netatmo.buildFeaturesCommon.js create mode 100644 server/services/netatmo/lib/device/netatmo.buildFeaturesCommonTemp.js create mode 100644 server/services/netatmo/lib/device/netatmo.buildFeaturesSignal.js create mode 100644 server/services/netatmo/lib/device/netatmo.buildFeaturesSpecifEnergy.js create mode 100644 server/services/netatmo/lib/device/netatmo.convertDevice.js create mode 100644 server/services/netatmo/lib/device/netatmo.deviceMapping.js create mode 100644 server/services/netatmo/lib/device/netatmo.updateNAPlug.js create mode 100644 server/services/netatmo/lib/device/netatmo.updateNATherm1.js create mode 100644 server/services/netatmo/lib/index.js create mode 100644 server/services/netatmo/lib/netatmo.connect.js create mode 100644 server/services/netatmo/lib/netatmo.disconnect.js create mode 100644 server/services/netatmo/lib/netatmo.discoverDevices.js create mode 100644 server/services/netatmo/lib/netatmo.getAccessToken.js create mode 100644 server/services/netatmo/lib/netatmo.getConfiguration.js create mode 100644 server/services/netatmo/lib/netatmo.getRefreshToken.js create mode 100644 server/services/netatmo/lib/netatmo.getStatus.js create mode 100644 server/services/netatmo/lib/netatmo.init.js create mode 100644 server/services/netatmo/lib/netatmo.loadDeviceDetails.js create mode 100644 server/services/netatmo/lib/netatmo.loadDevices.js create mode 100644 server/services/netatmo/lib/netatmo.loadThermostatDetails.js create mode 100644 server/services/netatmo/lib/netatmo.pollRefreshingToken.js create mode 100644 server/services/netatmo/lib/netatmo.pollRefreshingValues.js create mode 100644 server/services/netatmo/lib/netatmo.refreshingTokens.js create mode 100644 server/services/netatmo/lib/netatmo.retrieveTokens.js create mode 100644 server/services/netatmo/lib/netatmo.saveConfiguration.js create mode 100644 server/services/netatmo/lib/netatmo.saveStatus.js create mode 100644 server/services/netatmo/lib/netatmo.setTokens.js create mode 100644 server/services/netatmo/lib/netatmo.setValue.js create mode 100644 server/services/netatmo/lib/netatmo.updateValues.js create mode 100644 server/services/netatmo/lib/utils/netatmo.buildScopesConfig.js create mode 100644 server/services/netatmo/lib/utils/netatmo.constants.js create mode 100644 server/services/netatmo/package-lock.json create mode 100644 server/services/netatmo/package.json create mode 100644 server/test/services/netatmo/controllers/netatmo.controller.test.js create mode 100644 server/test/services/netatmo/index.test.js create mode 100644 server/test/services/netatmo/lib/device/netatmo.convertDevice.test.js create mode 100644 server/test/services/netatmo/lib/device/netatmo.deviceMapping.test.js create mode 100644 server/test/services/netatmo/lib/device/netatmo.updateNAPlug.test.js create mode 100644 server/test/services/netatmo/lib/device/netatmo.updateNATherm1.test.js create mode 100644 server/test/services/netatmo/lib/index.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.connect.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.disconnect.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.discoverDevices.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.getAccessToken.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.getConfiguration.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.getRefreshToken.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.getStatus.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.init.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.loadDeviceDetails.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.loadDevices.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.loadThermostatDetails.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.pollRefreshingTokens.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.pollRefreshingValues.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.refreshingTokens.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.retrieveToken.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.saveConfiguration.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.saveStatus.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.setTokens.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.setValue.test.js create mode 100644 server/test/services/netatmo/lib/netatmo.updateValues.test.js create mode 100644 server/test/services/netatmo/netatmo.convertDevices.mock.test.json create mode 100644 server/test/services/netatmo/netatmo.discoverDevices.mock.test.json create mode 100644 server/test/services/netatmo/netatmo.getThermostat.mock.test.json create mode 100644 server/test/services/netatmo/netatmo.homesdata.mock.test.json create mode 100644 server/test/services/netatmo/netatmo.homestatus.mock.test.json create mode 100644 server/test/services/netatmo/netatmo.loadDevices.mock.test.json create mode 100644 server/test/services/netatmo/netatmo.loadDevicesDetails.mock.test.json create mode 100644 server/test/services/netatmo/netatmo.loadThermostatDetails.mock.test.json create mode 100644 server/test/services/netatmo/netatmo.mock.test.js diff --git a/front/src/assets/integrations/cover/netatmo.jpg b/front/src/assets/integrations/cover/netatmo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ba25c09890bd2099b7926b51215bbe715a5441cf GIT binary patch literal 29655 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 i(sgjpn5b1+Z9k+p<$ipHW;p9~D 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%0A4k(6#CIW|C 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 literal 0 HcmV?d00001 diff --git a/front/src/components/app.jsx b/front/src/components/app.jsx index 8eb49ff786..65e4408662 100644 --- a/front/src/components/app.jsx +++ b/front/src/components/app.jsx @@ -139,6 +139,11 @@ import TuyaEditPage from '../routes/integration/all/tuya/edit-page'; import TuyaSetupPage from '../routes/integration/all/tuya/setup-page'; import TuyaDiscoverPage from '../routes/integration/all/tuya/discover-page'; +// Netatmo integration +import NetatmoPage from '../routes/integration/all/netatmo/device-page'; +import NetatmoSetupPage from '../routes/integration/all/netatmo/setup-page'; +import NetatmoDiscoverPage from '../routes/integration/all/netatmo/discover-page'; + // Sonos integration import SonosDevicePage from '../routes/integration/all/sonos/device-page'; import SonosDiscoveryPage from '../routes/integration/all/sonos/discover-page'; @@ -284,6 +289,10 @@ const AppRouter = connect( + + + + diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index dc480bc390..f4ff49b0e5 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -509,6 +509,82 @@ "rotation18O": "180°", "rotation27O": "270°" }, + "netatmo": { + "title": "Netatmo", + "description": "Netatmo-Geräte steuern", + "deviceTab": "Geräte", + "discoverTab": "Netatmo-Entdeckung", + "setupTab": "Konfiguration", + "documentation": "Netatmo-Dokumentation", + "discoverDeviceDescr": "Netatmo-Geräte automatisch scannen", + "status": { + "notConfigured": "Der Netatmo-Dienst ist nicht konfiguriert", + "disconnect": "Gladys ist nicht mit Netatmo verbunden", + "notConnected": "Gladys konnte keine Verbindung zum Netatmo-Konto herstellen, bitte überprüfen Sie Ihre Anmeldeinformationen auf der ", + "setupPageLink": "Netatmo-Konfigurationsseite.", + "connect": "Gladys ist mit Netatmo verbunden", + "connecting": "Konfiguration gespeichert. Verbindung zu Ihrem Netatmo-Konto wird hergestellt...", + "processingToken": "Verbindung zu Ihrem Netatmo-Konto... Zugriffstoken wird abgerufen.", + "getDevicesValues": "Datenwiederherstellung läuft...", + "dicoveringDevices": "Gerätewiederherstellung läuft...", + "errorConnecting": { + "other_error": "Fehler bei der Autorisierung. Sie können den Fehler in der Konsole Ihres Browsers mit einem Rechtsklick untersuchen. Wenn der Fehler weiterhin besteht, posten Sie bitte die Protokolle im Forum.", + "access_denied": "Autorisierung abgelehnt, bitte versuchen Sie es erneut und akzeptieren Sie den Zugriff auf Ihre Daten.", + "invalid_client": "Ungültige Kunden-ID. Bitte überprüfen Sie Ihre Netatmo-Kontoinformationen und dass das Konto nicht gesperrt oder deaktiviert ist Meine App.", + "get_access_token_fail": "Falsche Informationen eingegeben oder Konto deaktiviert. Bitte überprüfen Sie Ihre Netatmo-Kontoinformationen und dass das Konto nicht gesperrt oder deaktiviert ist Meine App." + } + }, + "device": { + "title": "Netatmo-Geräte in Gladys", + "descriptionInformation": "Der Statusabruf erfolgt derzeit alle 2 Minuten (Einschränkung der Netatmo-API).", + "noDeviceFound": "Noch keine Netatmo-Geräte hinzugefügt.", + "sidLabel": "Netatmo-SID des Geräts", + "nameLabel": "Gerätename", + "namePlaceholder": "Geben Sie den Namen Ihres Geräts ein", + "modelLabel": "Modell", + "roomLabel": "Raum", + "connectedPlugLabel": "Mit der Brücke verbunden", + "roomNetatmoApiLabel": "Raum in der Netatmo-API", + "featuresLabel": "Funktionen", + "saveButton": "Speichern", + "deleteButton": "Löschen", + "noValueReceived": "Kein Wert empfangen." + }, + "discover": { + "title": "Ihre mit Gladys kompatiblen Netatmo-Geräte", + "description": "Ihre Netatmo-Geräte müssen zuerst zu Ihrem Netatmo-Konto hinzugefügt werden, bevor sie zu Gladys hinzugefügt werden können, das derzeit nur Thermostate unterstützt, die mit ihrem Plug verbunden sind. Die erste Entdeckung erfolgt automatisch. Drücken Sie 'Aktualisieren', wenn Sie die neuen Funktionen Ihrer Geräte abrufen möchten.", + "descriptionCompatibility": "Für alle Anfragen zur Hinzufügung von Geräten und/oder Funktionen, besuchen Sie bitte das Gladys Assistant Forum - Kategorie \"Funktionsanfragen\".", + "descriptionInformation": "Der Statusabruf erfolgt derzeit alle 2 Minuten (Einschränkung der Netatmo-API).", + "noDeviceFound": "Keine Netatmo-Geräte gefunden. Haben Sie alle Schritte der Dokumentation befolgt, um Ihre Geräte in der Netatmo-API zu registrieren?", + "alreadyCreatedButton": "Bereits erstellt", + "updateButton": "Aktualisieren", + "unmanagedModelButton": "Nicht unterstütztes Modell", + "scan": "Scannen", + "refresh": "Aktualisieren" + }, + "setup": { + "title": "Netatmo-Konfiguration", + "description": "Sie können Gladys mit Ihrem Netatmo-Konto verbinden, um die zugehörigen Geräte zu steuern.", + "descriptionCreateAccount": "Sie müssen ein Konto auf Netatmo Connect erstellen.", + "descriptionCreateProject": "Danach müssen Sie eine \"Anwendung\" in Ihrem Netatmo-Entwicklerkonto über das Menü Meine App erstellen.", + "descriptionGetKeys": "Dann haben Sie Zugriff auf zwei Schlüssel: \"Kunden-ID\" und \"Kundengeheimnis\", die Sie unten kopieren können.", + "descriptionScopeInformation": "Wenn Sie Gladys mit Ihrer dedizierten Netatmo-App verbinden, erteilen Sie ihr die Berechtigung, auf Ihre Daten zum Lesen und Schreiben zuzugreifen (genannt Scopes \"read\" und \"write\"). Sie müssen nichts konfigurieren, diese Scopes sind automatisch integriert und werden Ihnen bei der Verbindungsanfrage angezeigt (siehe Dokumentation bei Bedarf). Da Gladys lokal installiert ist, werden keine dieser Daten preisgegeben.", + "titleAdditionalInformation": "Weitere Informationen über die Funktionsweise der \"Energy\"-API:", + "descriptionAdditionalInformation": "Temperaturbefehle werden auf Raumniveau ausgeführt. Daher sind beide Informationen als Funktionen für mehr Kontrolle verfügbar. Wenn Sie andere Temperatursensoren im gleichen Raum wie Ihr Thermostat haben, wird die Durchschnittstemperatur dieses Raums berücksichtigt, um den Heizungssteuerschalter auszulösen.", + "clientIdLabel": "Kunden-ID", + "clientIdPlaceholder": "Kunden-ID von My Apps in Netatmo Connect", + "clientSecretLabel": "Kundengeheimnis", + "clientSecretPlaceholder": "Kundengeheimnis von My Apps in Netatmo Connect", + "connectionInfoLabel": "Wenn Sie diese Informationen ändern, ist ein neues Token erforderlich. Durch das Trennen der Verbindung wird auch Ihr Zugriffstoken gelöscht. Wenn Sie auf diese Schaltflächen klicken, wird daher eine neue Verbindungsanfrage an die Netatmo-API gestellt.", + "saveLabel": "Speichern und verbinden", + "disconnectLabel": "Trennen" + }, + "error": { + "defaultError": "Ein Fehler ist bei der Registrierung des Geräts aufgetreten.", + "defaultDeletionError": "Ein Fehler ist bei der Löschung des Geräts aufgetreten.", + "conflictError": "Das aktuelle Gerät ist bereits in Gladys." + } + }, "tasmota": { "title": "Tasmota", "description": "Steuere deine Tasmota-Geräte über HTTP oder MQTT.", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 0bb80bc914..1b832441d4 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -509,6 +509,82 @@ "rotation18O": "180°", "rotation27O": "270°" }, + "netatmo": { + "title": "Netatmo", + "description": "Control Netatmo equipment", + "deviceTab": "Devices", + "discoverTab": "Netatmo Discovery", + "setupTab": "Setup", + "documentation": "Netatmo Documentation", + "discoverDeviceDescr": "Automatically scan Netatmo devices", + "status": { + "notConfigured": "The Netatmo service is not configured", + "disconnect": "Gladys is not connected to Netatmo", + "notConnected": "Gladys failed to connect to the Netatmo account, please verify your credentials on the ", + "setupPageLink": "Netatmo Setup Page.", + "connect": "Gladys is connected to Netatmo", + "connecting": "Configuration saved. Connecting to your Netatmo account...", + "processingToken": "Connecting to your Netatmo account... Retrieving access token.", + "getDevicesValues": "Data recovery in progress...", + "dicoveringDevices": "Device recovery in progress...", + "errorConnecting": { + "other_error": "Error during authorization. You can inspect the error in your browser's console with a right click. If the error persists, please post the logs on the forum.", + "access_denied": "Authorization declined, please try again and accept the request for access to your data.", + "invalid_client": "Invalid client ID. Please verify your Netatmo account information and that the account is not banned or disabled on My App.", + "get_access_token_fail": "Incorrect information entered or account disabled. Please check your Netatmo account information and that the account is not banned or disabled My App." + } + }, + "device": { + "title": "Netatmo Devices in Gladys", + "descriptionInformation": "State retrieval is currently done every 2 minutes (Netatmo API limitation).", + "noDeviceFound": "No Netatmo devices have been added yet.", + "sidLabel": "Netatmo device SID", + "nameLabel": "Device name", + "namePlaceholder": "Enter your device name", + "modelLabel": "Model", + "roomLabel": "Room", + "connectedPlugLabel": "Connected to bridge", + "roomNetatmoApiLabel": "Room in Netatmo API", + "featuresLabel": "Features", + "saveButton": "Save", + "deleteButton": "Delete", + "noValueReceived": "No value received." + }, + "discover": { + "title": "Your Netatmo Devices Compatible with Gladys", + "description": "Your Netatmo devices must be added to your Netatmo account before being added to Gladys, which currently only supports Thermostats associated with their Plug. The first discovery is done automatically. Press 'Refresh' if you want to retrieve new features of your devices.", + "descriptionCompatibility": "For any requests to add devices and/or features, please visit the Gladys Assistant Forum - Feature Requests Category.", + "descriptionInformation": "State retrieval is currently done every 2 minutes (Netatmo API limitation).", + "noDeviceFound": "No Netatmo devices were found. If you own a Netatmo Thermostat, have you completed all the steps in the documentation to register your devices on the Netatmo API?", + "alreadyCreatedButton": "Already Created", + "unmanagedModelButton": "Unsupported model", + "updateButton": "Update", + "scan": "Scan", + "refresh": "Refresh" + }, + "setup": { + "title": "Netatmo Setup", + "description": "You can connect Gladys to your Netatmo account to control the associated devices.", + "descriptionCreateAccount": "You need to create an account on Netatmo Connect.", + "descriptionCreateProject": "You will then need to create an \"application\" in your Netatmo developer account via the My App.", + "descriptionGetKeys": "You will then have access to two keys: \"client ID\" and \"client secret\" to copy below.", + "descriptionScopeInformation": "When you connect Gladys to your dedicated Netatmo App, you grant it permission to access your data in read and write modes (referred to as \"read\" and \"write\" scopes). You don't have to configure anything, these scopes are automatically integrated and will be presented to you during the connection request (see documentation if needed). Of course, since Gladys is installed locally, none of these data is exposed.", + "titleAdditionalInformation": "Additional Information on the Operation of the \"Energy\" API:", + "descriptionAdditionalInformation": "Temperature commands are performed at the room level. Therefore, both pieces of information are available as features for more control. If you have other temperature sensors in the same room as your Thermostat, it's the average temperature of that room that will be considered to trigger the heating control switch.", + "clientIdLabel": "Client ID", + "clientIdPlaceholder": "Client ID from My Apps in Netatmo Connect", + "clientSecretLabel": "Client Secret", + "clientSecretPlaceholder": "Client secret from My Apps in Netatmo Connect", + "connectionInfoLabel": "When you change this information, a new token is required. Disconnecting will also erase your access token. A new connection request will therefore be made to the Netatmo API when you click on these buttons.", + "saveLabel": "Save and connect", + "disconnectLabel": "Disconnect" + }, + "error": { + "defaultError": "An error occurred while registering the device.", + "defaultDeletionError": "An error occurred while deleting the device.", + "conflictError": "The current device is already in Gladys." + } + }, "tasmota": { "title": "Tasmota", "description": "Control your Tasmota devices in HTTP or MQTT.", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 245dd65df4..1bcbaad197 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -637,6 +637,82 @@ "rotation18O": "180°", "rotation27O": "270°" }, + "netatmo": { + "title": "Netatmo", + "description": "Contrôler les équipements Netatmo", + "deviceTab": "Appareils", + "discoverTab": "Découverte Netatmo", + "setupTab": "Configuration", + "documentation": "Documentation Netatmo", + "discoverDeviceDescr": "Scanner automatiquement les appareils Netatmo", + "status": { + "notConfigured": "Le service Netatmo n'est pas configuré", + "disconnect": "Gladys n'est pas connectée à Netatmo", + "notConnected": "Gladys n'a pas réussi à se connecter au compte Netatmo, veuillez vérifier vos informations d'identification sur la ", + "setupPageLink": "Page de configuration Netatmo.", + "connect": "Gladys est connectée à Netatmo", + "connecting": "Configuration sauvegardée. Connexion à votre compte Netatmo en cours...", + "processingToken": "Connexion à votre compte Netatmo... Récupération du token d'accès.", + "getDevicesValues": "Récupération des données en cours...", + "dicoveringDevices": "Récupération des appareils en cours...", + "errorConnecting": { + "other_error": "Erreur lors de l'authorisation. Vous pouvez inspecter par un clique droit l'erreur dans la console de votre navigateur. Si l'erreur persiste, veuillez poster les logs sur le forum.", + "access_denied": "Autorisation déclinée, Veuillez tenter de nouveau et accepter la demande d'accès à vos données.", + "invalid_client": "Identifiant client invalide. Veuillez vérifier les informations de votre compte Netatmo et que le compte n'est pas bani ou désactivé My App.", + "get_access_token_fail": "Informations renseignées erronnées ou compte désactivé. Veuillez vérifier les informations de votre compte Netatmo et que le compte n'est pas bani ou désactivé My App." + } + }, + "device": { + "title": "Appareils Netatmo dans Gladys", + "descriptionInformation": "La récupération des états se fait actuellement toutes les 2 minutes (limitation de l'API Netatmo).", + "noDeviceFound": "Aucun appareil Netatmo n'a encore été ajouté.", + "sidLabel": "SID Netatmo de l'appareil", + "nameLabel": "Nom de l'appareil", + "namePlaceholder": "Entrez le nom de votre appareil", + "modelLabel": "Modèle", + "roomLabel": "Pièce", + "connectedPlugLabel": "Connecté au pont", + "roomNetatmoApiLabel": "Pièce dans l'API Netatmo", + "featuresLabel": "Fonctionnalités", + "saveButton": "Sauvegarder", + "deleteButton": "Supprimer", + "noValueReceived": "Aucune valeur reçue." + }, + "discover": { + "title": "Vos appareils Netatmo compatibles avec Gladys", + "description": "Vos appareils Netatmo doivent être ajoutés à votre compte Netatmo avant d'être ajoutés à Gladys qui ne supporte actuellement que les Thermostats associés à leur Plug. La 1ère découverte se fait automatiquement. Appuyer sur 'Rafraichir' si vous souhaitez récupérer les nouvelles fonctionnalités de vos appareils.", + "descriptionCompatibility": "Pour toute demande d'ajout d'appareils et/ou de fonctionnalités, veuillez vous rendre sur le Forum Gladys Assistant - Catégorie \"Demande de fonctionnalités\".", + "descriptionInformation": "La récupération des états se fait actuellement toutes les 2 minutes (limitation de l'API Netatmo).", + "noDeviceFound": "Aucun appareil Netatmo n'a été trouvé. Si vous possédez un Thermostat Netatmo, avez-vous bien réalisé toutes les étapes de la documentation pour enregistrer vos appareils sur l'API Netatmo ?", + "alreadyCreatedButton": "Déjà créé", + "updateButton": "Mettre à jour", + "unmanagedModelButton": "Modèle non pris en charge", + "scan": "Scanner", + "refresh": "Rafraîchir" + }, + "setup": { + "title": "Configuration Netatmo", + "description": "Vous pouvez connecter Gladys à votre compte Netatmo pour commander les appareils associés.", + "descriptionCreateAccount": "Vous avez besoin de créer un compte sur Netatmo Connect.", + "descriptionCreateProject": "Vous devrez ensuite créer une \"application\" dans votre compte développeur Netatmo via le menu My App.", + "descriptionGetKeys": "Vous aurez alors accès aux deux clés : \"client ID\" et \"client secret\" à copier ci-dessous.", + "descriptionScopeInformation": "Lorsque vous connectez Gladys à votre App Netatmo dédiée, vous lui donnez l'autorisation d'accéder à vos données en lecture et en écriture (appelé scopes \"read\" et \"write\"). Vous n'avez rien à configuré, ces scopes sont automatiquement intégrés et vous serons exposés lors de la demande de connexion (voir documentation au besoin). Bien entendu, Gladys étant installée en local, aucune de ces données ne se retrouve exposées.", + "titleAdditionalInformation": "Informations complémentaires sur le fonctionnement de l'API \"Energy\" :", + "descriptionAdditionalInformation": "Les commandes de températures sont effectuées au niveau de la pièce. Les 2 informations sont donc disponibles en fonctionnalités pour plus de contrôle. Si vous possédez d'autres capteurs de température dans la même pièce que votre Thermostat, c'est la température moyenne de cette pièce qui sera prise en compte pour déclencher le commutateur de contrôle de chauffe.", + "clientIdLabel": "Identifiant client", + "clientIdPlaceholder": "client ID de My Apps dans Netatmo Connect", + "clientSecretLabel": "Secret client", + "clientSecretPlaceholder": "client secret de My Apps dans Netatmo Connect", + "connectionInfoLabel": "Lorsque vous modifiez ces informations, un nouveau jeton est requis. La déconnexion effacera également votre jeton d’accès. Une nouvelle demande de connexion sera donc faite à l'API Netatmo lorsque vous cliquerez sur ces boutons.", + "saveLabel": "Sauvegarder et connecter", + "disconnectLabel": "Déconnecter" + }, + "error": { + "defaultError": "Une erreur s'est produite lors de l'enregistrement de l'appareil.", + "defaultDeletionError": "Une erreur s'est produite lors de la suppression de l'appareil.", + "conflictError": "L'appareil actuel est déjà dans Gladys." + } + }, "tasmota": { "title": "Tasmota", "description": "Contrôler vos appareils Tasmota en MQTT ou HTTP", diff --git a/front/src/config/integrations/devices.json b/front/src/config/integrations/devices.json index d4bed59b09..88f52ebcd0 100644 --- a/front/src/config/integrations/devices.json +++ b/front/src/config/integrations/devices.json @@ -74,6 +74,11 @@ "link": "node-red", "img": "/assets/integrations/cover/node-red.jpg" }, + { + "key": "netatmo", + "link": "netatmo", + "img": "/assets/integrations/cover/netatmo.jpg" + }, { "key": "sonos", "link": "sonos", diff --git a/front/src/routes/integration/all/netatmo/NetatmoDeviceBox.jsx b/front/src/routes/integration/all/netatmo/NetatmoDeviceBox.jsx new file mode 100644 index 0000000000..e547aa29d4 --- /dev/null +++ b/front/src/routes/integration/all/netatmo/NetatmoDeviceBox.jsx @@ -0,0 +1,390 @@ +import { Component } from 'preact'; +import { Text, Localizer, MarkupText } from 'preact-i18n'; +import cx from 'classnames'; +import { connect } from 'unistore/preact'; +import dayjs from 'dayjs'; +import get from 'get-value'; +import DeviceFeatures from '../../../../components/device/view/DeviceFeatures'; +import BatteryLevelFeature from '../../../../components/device/view/BatteryLevelFeature'; +import { DEVICE_FEATURE_CATEGORIES } from '../../../../../../server/utils/constants'; +import { GITHUB_BASE_URL, PARAMS } from '../../../../../../server/services/netatmo/lib/utils/netatmo.constants'; +import styles from './style.css'; + +const createGithubUrl = device => { + const title = encodeURIComponent(`Netatmo: Add device ${device.model}`); + const body = encodeURIComponent(`\`\`\`\n${JSON.stringify(device, null, 2)}\n\`\`\``); + return `${GITHUB_BASE_URL}?title=${title}&body=${body}`; +}; + +class NetatmoDeviceBox extends Component { + componentWillMount() { + this.setState({ + device: this.props.device, + user: this.props.user + }); + } + + componentWillReceiveProps(nextProps) { + this.setState({ + device: nextProps.device + }); + } + + updateName = e => { + this.setState({ + device: { + ...this.state.device, + name: e.target.value + } + }); + }; + + updateRoom = e => { + this.setState({ + device: { + ...this.state.device, + room_id: e.target.value + } + }); + }; + + saveDevice = async () => { + this.setState({ + loading: true, + errorMessage: null + }); + try { + const savedDevice = await this.props.httpClient.post(`/api/v1/device`, this.state.device); + this.setState({ + device: savedDevice, + isSaving: true + }); + } catch (e) { + let errorMessage = 'integration.netatmo.error.defaultError'; + if (e.response.status === 409) { + errorMessage = 'integration.netatmo.error.conflictError'; + } + this.setState({ + errorMessage + }); + } + this.setState({ + loading: false + }); + }; + + deleteDevice = async () => { + this.setState({ + loading: true, + errorMessage: null, + tooMuchStatesError: false, + statesNumber: undefined + }); + try { + if (this.state.device.created_at) { + await this.props.httpClient.delete(`/api/v1/device/${this.state.device.selector}`); + } + this.props.getNetatmoDevices(); + } 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({ + errorMessage: 'integration.netatmo.error.defaultDeletionError' + }); + } + } + this.setState({ + loading: false + }); + }; + + getDeviceProperty = () => { + const device = this.state.device; + if (!device.features) { + return null; + } + const batteryLevelDeviceFeature = device.features.find( + deviceFeature => deviceFeature.category === DEVICE_FEATURE_CATEGORIES.BATTERY + ); + const batteryLevel = get(batteryLevelDeviceFeature, 'last_value'); + let mostRecentValueAt = null; + device.features.forEach(feature => { + if (feature.last_value_changed && new Date(feature.last_value_changed) > mostRecentValueAt) { + mostRecentValueAt = new Date(feature.last_value_changed); + } + }); + + let roomNameNetatmo = null; + const roomNameParam = device.params.find(param => param.name === PARAMS.ROOM_NAME); + if (roomNameParam) { + roomNameNetatmo = roomNameParam.value; + } + + let plugName = null; + const plugNameParam = device.params.find(param => param.name === PARAMS.PLUG_NAME); + if (plugNameParam) { + plugName = plugNameParam.value; + } + + const isDeviceReachable = (device, now = new Date()) => { + const isRecent = (date, time) => (now - new Date(date)) / (1000 * 60) <= time; + const hasRecentFeature = device.features.some(feature => isRecent(feature.last_value_changed, 15)); + const isNetatmoDeviceReachable = + device.deviceNetatmo && + (device.deviceNetatmo.reachable || + device.deviceNetatmo.room.reachable || + isRecent(device.deviceNetatmo.last_plug_seen * 1000, 180) || + isRecent(device.deviceNetatmo.last_therm_seen * 1000, 180)); + return hasRecentFeature || isNetatmoDeviceReachable; + }; + const online = isDeviceReachable(device); + + return { + batteryLevel, + mostRecentValueAt, + roomNameNetatmo, + plugName, + online + }; + }; + + render( + { + deviceIndex, + editable, + deleteButton, + saveButton, + updateButton, + alreadyCreatedButton, + showMostRecentValueAt, + housesWithRooms + }, + { device, user, loading, errorMessage, tooMuchStatesError, statesNumber } + ) { + const validModel = (device.features && device.features.length > 0) || !device.not_handled; + const { batteryLevel, mostRecentValueAt, roomNameNetatmo, plugName, online } = this.getDeviceProperty(); + const sidDevice = device.external_id.replace('netatmo:', '') || (device.deviceNetatmo && device.deviceNetatmo.id); + const saveButtonCondition = + (saveButton && !alreadyCreatedButton) || (saveButton && !this.state.isSaving && alreadyCreatedButton); + const modelImage = `/assets/integrations/devices/netatmo/netatmo-${device.model}.jpg`; + return ( +
+
+
+ +
}> + +  {device.name} +
+
+ {showMostRecentValueAt && batteryLevel && ( +
+ +
+ )} +
+
+
+
+
+ {errorMessage && ( +
+ +
+ )} + {tooMuchStatesError && ( +
+ +
+ )} +
+ { + e.target.onerror = null; + e.target.src = '/assets/integrations/cover/netatmo.jpg'; + }} + alt={`Image de ${device.name}`} + className={styles['device-image-container']} + /> +
+
+ + + } + disabled={!editable || !validModel} + /> + +
+ +
+ + +
+ {plugName && ( +
+ + +
+ )} + {sidDevice && ( +
+ + +
+ )} + {roomNameNetatmo && ( +
+ + +
+ )} + + {validModel && ( +
+ + +
+ )} + + {validModel && ( +
+ + +
+ )} + +
+ {validModel && this.state.isSaving && alreadyCreatedButton && ( + + )} + + {validModel && updateButton && ( + + )} + + {validModel && saveButtonCondition && ( + + )} + + {validModel && deleteButton && ( + + )} + + {!validModel && ( +
+
+ +
+ + + +
+ )} + + {validModel && showMostRecentValueAt && ( +

+ {mostRecentValueAt ? ( + + ) : ( + + )} +

+ )} +
+
+
+
+
+
+ ); + } +} + +export default connect('httpClient,user', {})(NetatmoDeviceBox); diff --git a/front/src/routes/integration/all/netatmo/NetatmoPage.jsx b/front/src/routes/integration/all/netatmo/NetatmoPage.jsx new file mode 100644 index 0000000000..1a1265f6bd --- /dev/null +++ b/front/src/routes/integration/all/netatmo/NetatmoPage.jsx @@ -0,0 +1,72 @@ +import { Text } from 'preact-i18n'; +import { Link } from 'preact-router/match'; +import DeviceConfigurationLink from '../../../../components/documentation/DeviceConfigurationLink'; + +const NetatmoPage = props => ( +
+
+
+
+
+
+

+ +

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

+ +

+
+ + } + /> + +
+
+
+
+
+
+ +
+

+ +

+
+
+ {netatmoDevices && + netatmoDevices.length > 0 && + netatmoDevices.map((device, index) => ( + + ))} + {!netatmoDevices || (netatmoDevices.length === 0 && )} +
+
+
+
+
+ ); + } +} + +export default connect('httpClient', {})(DeviceTab); diff --git a/front/src/routes/integration/all/netatmo/device-page/EmptyState.jsx b/front/src/routes/integration/all/netatmo/device-page/EmptyState.jsx new file mode 100644 index 0000000000..5807b4ab4e --- /dev/null +++ b/front/src/routes/integration/all/netatmo/device-page/EmptyState.jsx @@ -0,0 +1,23 @@ +import { Text } from 'preact-i18n'; +import { Link } from 'preact-router/match'; +import cx from 'classnames'; +import style from './style.css'; + +const EmptyState = () => ( +
+
+ + +
+ + + + +
+
+
+); + +export default EmptyState; diff --git a/front/src/routes/integration/all/netatmo/device-page/StateConnection.jsx b/front/src/routes/integration/all/netatmo/device-page/StateConnection.jsx new file mode 100644 index 0000000000..b20016b4d1 --- /dev/null +++ b/front/src/routes/integration/all/netatmo/device-page/StateConnection.jsx @@ -0,0 +1,45 @@ +import { Text } from 'preact-i18n'; +import { STATUS } from '../../../../../../../server/services/netatmo/lib/utils/netatmo.constants'; + +const StateConnection = props => ( +
+ {!props.accessDenied && + ((props.connectNetatmoStatus === STATUS.DISCOVERING_DEVICES && ( +

+ +

+ )) || + (props.connectNetatmoStatus === STATUS.GET_DEVICES_VALUES && ( +

+ +

+ )) || + (props.connectNetatmoStatus === STATUS.CONNECTING && ( +

+ +

+ )) || + (props.connectNetatmoStatus === STATUS.PROCESSING_TOKEN && ( +

+ +

+ )) || + (props.connected && ( +

+ +

+ )) || + (props.connectNetatmoStatus === STATUS.DISCONNECTED && ( +

+ +

+ )) || + (props.connectNetatmoStatus === STATUS.NOT_INITIALIZED && ( +

+ +

+ )))} +
+); + +export default StateConnection; diff --git a/front/src/routes/integration/all/netatmo/device-page/index.js b/front/src/routes/integration/all/netatmo/device-page/index.js new file mode 100644 index 0000000000..000c89b8c7 --- /dev/null +++ b/front/src/routes/integration/all/netatmo/device-page/index.js @@ -0,0 +1,137 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import DeviceTab from './DeviceTab'; +import NetatmoPage from '../NetatmoPage'; +import { RequestStatus } from '../../../../../utils/consts'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../../../server/utils/constants'; +import { STATUS } from '../../../../../../../server/services/netatmo/lib/utils/netatmo.constants'; + +class DevicePage extends Component { + loadStatus = async () => { + try { + const netatmoStatus = await this.props.httpClient.get('/api/v1/service/netatmo/status'); + this.setState({ + connectNetatmoStatus: netatmoStatus.status, + connected: netatmoStatus.connected, + configured: netatmoStatus.configured + }); + } catch (e) { + this.setState({ + netatmoConnectionError: RequestStatus.NetworkError, + errored: true + }); + console.error(e); + } finally { + this.setState({ + showConnect: true + }); + } + }; + + updateStatus = async state => { + let connected = false; + let configured = false; + if ( + state.status === STATUS.CONNECTED || + state.status === STATUS.GET_DEVICES_VALUES || + state.status === STATUS.DISCOVERING_DEVICES + ) { + connected = true; + configured = true; + } else if (state.status === STATUS.NOT_INITIALIZED) { + connected = false; + configured = false; + } else { + connected = false; + configured = true; + } + await this.setState({ + connectNetatmoStatus: state.status, + connected, + configured + }); + }; + + updateStatusError = async state => { + switch (state.statusType) { + case STATUS.CONNECTING: + if (state.status !== 'other_error') { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + case STATUS.PROCESSING_TOKEN: + if (state.status === 'get_access_token_fail') { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else if (state.status === 'invalid_client') { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + } + }; + + handleStateUpdateFromChild = newState => { + this.setState(newState); + }; + + componentDidMount() { + this.loadStatus(); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.NETATMO.STATUS, this.updateStatus); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.CONNECTING, this.updateStatusError); + this.props.session.dispatcher.addListener( + WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.PROCESSING_TOKEN, + this.updateStatus + ); + } + + componentWillUnmount() { + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.NETATMO.STATUS, this.updateStatus); + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.CONNECTING, this.updateStatus); + this.props.session.dispatcher.removeListener( + WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.PROCESSING_TOKEN, + this.updateStatus + ); + } + + render(props, state, { loading }) { + return ( + + + + ); + } +} + +export default connect('user,session,httpClient', {})(DevicePage); diff --git a/front/src/routes/integration/all/netatmo/device-page/style.css b/front/src/routes/integration/all/netatmo/device-page/style.css new file mode 100644 index 0000000000..5a0b12ccc3 --- /dev/null +++ b/front/src/routes/integration/all/netatmo/device-page/style.css @@ -0,0 +1,7 @@ +.emptyStateDivBox { + margin-top: 35px; +} + +.netatmoListBody { + min-height: 200px +} diff --git a/front/src/routes/integration/all/netatmo/discover-page/DiscoverTab.jsx b/front/src/routes/integration/all/netatmo/discover-page/DiscoverTab.jsx new file mode 100644 index 0000000000..13bcd66940 --- /dev/null +++ b/front/src/routes/integration/all/netatmo/discover-page/DiscoverTab.jsx @@ -0,0 +1,135 @@ +import { Text, MarkupText } from 'preact-i18n'; +import cx from 'classnames'; + +import EmptyState from './EmptyState'; +import StateConnection from './StateConnection'; +import style from './style.css'; +import NetatmoDeviceBox from '../NetatmoDeviceBox'; +import { connect } from 'unistore/preact'; +import { Component } from 'preact'; +import { RequestStatus } from '../../../../../utils/consts'; + +class DiscoverTab extends Component { + async componentWillMount() { + this.getDiscoveredDevices(); + this.getHouses(); + } + + async getHouses() { + this.setState({ + housesGetStatus: RequestStatus.Getting + }); + try { + const params = { + expand: 'rooms' + }; + const housesWithRooms = await this.props.httpClient.get(`/api/v1/house`, params); + this.setState({ + housesWithRooms, + housesGetStatus: RequestStatus.Success + }); + } catch (e) { + this.setState({ + housesGetStatus: RequestStatus.Error + }); + } + } + + getDiscoveredDevices = async () => { + this.setState({ + loading: true + }); + try { + const discoveredDevices = await this.props.httpClient.get('/api/v1/service/netatmo/discover'); + this.setState({ + discoveredDevices, + loading: false, + errorLoading: false + }); + } catch (e) { + this.setState({ + discoveredDevices: [], + loading: false, + errorLoading: true + }); + } + }; + refreshDiscoveredDevices = async () => { + this.setState({ + loading: true + }); + try { + const discoveredDevices = await this.props.httpClient.get('/api/v1/service/netatmo/discover', { refresh: true }); + this.setState({ + discoveredDevices, + loading: false, + errorLoading: false + }); + } catch (e) { + this.setState({ + discoveredDevices: [], + loading: false, + errorLoading: true + }); + } + }; + + render(props, { loading, errorLoading, discoveredDevices, housesWithRooms }) { + return ( +
+
+

+ +

+
+ +
+
+
+ +
+

+ +

+

+ +

+

+ +

+
+
+
+
+
+ {discoveredDevices && + discoveredDevices.map((device, index) => ( + + ))} + {!discoveredDevices || (discoveredDevices.length === 0 && )} +
+
+
+
+
+ ); + } +} + +export default connect('httpClient', {})(DiscoverTab); diff --git a/front/src/routes/integration/all/netatmo/discover-page/EmptyState.jsx b/front/src/routes/integration/all/netatmo/discover-page/EmptyState.jsx new file mode 100644 index 0000000000..19c8bbdbc3 --- /dev/null +++ b/front/src/routes/integration/all/netatmo/discover-page/EmptyState.jsx @@ -0,0 +1,13 @@ +import { MarkupText } from 'preact-i18n'; +import cx from 'classnames'; +import style from './style.css'; + +const EmptyState = ({}) => ( +
+
+ +
+
+); + +export default EmptyState; diff --git a/front/src/routes/integration/all/netatmo/discover-page/StateConnection.jsx b/front/src/routes/integration/all/netatmo/discover-page/StateConnection.jsx new file mode 100644 index 0000000000..3917ae63af --- /dev/null +++ b/front/src/routes/integration/all/netatmo/discover-page/StateConnection.jsx @@ -0,0 +1,49 @@ +import { Text } from 'preact-i18n'; +import { Link } from 'preact-router/match'; +import { STATUS } from '../../../../../../../server/services/netatmo/lib/utils/netatmo.constants'; + +const StateConnection = props => ( +
+ {!props.accessDenied && + ((props.connectNetatmoStatus === STATUS.DISCOVERING_DEVICES && ( +

+ +

+ )) || + (props.connectNetatmoStatus === STATUS.GET_DEVICES_VALUES && ( +

+ +

+ )) || + (props.connectNetatmoStatus === STATUS.CONNECTING && ( +

+ +

+ )) || + (props.connectNetatmoStatus === STATUS.PROCESSING_TOKEN && ( +

+ +

+ )) || + (props.connected && ( +

+ +

+ )) || + (props.connectNetatmoStatus === STATUS.DISCONNECTED && ( +

+ +

+ )) || + ((props.errorLoading || props.connectNetatmoStatus === STATUS.NOT_INITIALIZED) && ( +

+ + + + +

+ )))} +
+); + +export default StateConnection; diff --git a/front/src/routes/integration/all/netatmo/discover-page/index.js b/front/src/routes/integration/all/netatmo/discover-page/index.js new file mode 100644 index 0000000000..3be375e98b --- /dev/null +++ b/front/src/routes/integration/all/netatmo/discover-page/index.js @@ -0,0 +1,133 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import DiscoverTab from './DiscoverTab'; +import NetatmoPage from '../NetatmoPage'; +import { RequestStatus } from '../../../../../utils/consts'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../../../server/utils/constants'; +import { STATUS } from '../../../../../../../server/services/netatmo/lib/utils/netatmo.constants'; + +class NetatmoDiscoverPage extends Component { + loadStatus = async () => { + try { + const netatmoStatus = await this.props.httpClient.get('/api/v1/service/netatmo/status'); + this.setState({ + connectNetatmoStatus: netatmoStatus.status, + connected: netatmoStatus.connected, + configured: netatmoStatus.configured + }); + } catch (e) { + this.setState({ + netatmoConnectionError: RequestStatus.NetworkError, + errored: true + }); + console.error(e); + } + }; + + updateStatus = async state => { + let connected = false; + let configured = false; + if ( + state.status === STATUS.CONNECTED || + state.status === STATUS.GET_DEVICES_VALUES || + state.status === STATUS.DISCOVERING_DEVICES + ) { + connected = true; + configured = true; + } else if (state.status === STATUS.NOT_INITIALIZED) { + connected = false; + configured = false; + } else { + connected = false; + configured = true; + } + await this.setState({ + connectNetatmoStatus: state.status, + connected, + configured + }); + }; + + updateStatusError = async state => { + switch (state.statusType) { + case STATUS.CONNECTING: + if (state.status !== 'other_error') { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + case STATUS.PROCESSING_TOKEN: + if (state.status === 'get_access_token_fail') { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else if (state.status === 'invalid_client') { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + } + }; + + handleStateUpdateFromChild = newState => { + this.setState(newState); + }; + + componentDidMount() { + this.loadStatus(); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.NETATMO.STATUS, this.updateStatus); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.CONNECTING, this.updateStatusError); + this.props.session.dispatcher.addListener( + WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.PROCESSING_TOKEN, + this.updateStatus + ); + } + + componentWillUnmount() { + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.NETATMO.STATUS, this.updateStatus); + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.CONNECTING, this.updateStatus); + this.props.session.dispatcher.removeListener( + WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.PROCESSING_TOKEN, + this.updateStatus + ); + } + + render(props, state, { loading }) { + return ( + + + + ); + } +} + +export default connect('user,session,httpClient', {})(NetatmoDiscoverPage); diff --git a/front/src/routes/integration/all/netatmo/discover-page/style.css b/front/src/routes/integration/all/netatmo/discover-page/style.css new file mode 100644 index 0000000000..c07dd386e6 --- /dev/null +++ b/front/src/routes/integration/all/netatmo/discover-page/style.css @@ -0,0 +1,7 @@ +.emptyStateDivBox { + margin-top: 89px; +} + +.netatmoListBody { + min-height: 200px; +} diff --git a/front/src/routes/integration/all/netatmo/setup-page/SetupTab.jsx b/front/src/routes/integration/all/netatmo/setup-page/SetupTab.jsx new file mode 100644 index 0000000000..b313deede6 --- /dev/null +++ b/front/src/routes/integration/all/netatmo/setup-page/SetupTab.jsx @@ -0,0 +1,171 @@ +import { Text, Localizer, MarkupText } from 'preact-i18n'; +import cx from 'classnames'; + +import style from './style.css'; +import StateConnection from './StateConnection'; +import { RequestStatus } from '../../../../../utils/consts'; +import { STATUS } from '../../../../../../../server/services/netatmo/lib/utils/netatmo.constants'; +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; + +class SetupTab extends Component { + showClientSecretTimer = null; + async disconnectNetatmo(e) { + e.preventDefault(); + + await this.setState({ + netatmoDisconnectStatus: RequestStatus.Getting + }); + try { + await this.props.httpClient.post('/api/v1/service/netatmo/disconnect'); + this.props.updateStateInIndex({ connectNetatmoStatus: STATUS.DISCONNECTED }); + await this.setState({ + netatmoDisconnectStatus: RequestStatus.Success + }); + } catch (e) { + await this.setState({ + netatmoSaveSettingsStatus: RequestStatus.Error + }); + } + } + updateClientId = e => { + this.props.updateStateInIndex({ netatmoClientId: e.target.value }); + }; + updateClientSecret = e => { + this.props.updateStateInIndex({ netatmoClientSecret: e.target.value }); + }; + toggleClientSecret = () => { + const { showClientSecret } = this.state; + + if (this.showClientSecretTimer) { + clearTimeout(this.showClientSecretTimer); + this.showClientSecretTimer = null; + } + + this.setState({ showClientSecret: !showClientSecret }); + + if (!showClientSecret) { + this.showClientSecretTimer = setTimeout(() => this.setState({ showClientSecret: false }), 5000); + } + }; + + componentWillUnmount() { + if (this.showClientSecretTimer) { + clearTimeout(this.showClientSecretTimer); + this.showClientSecretTimer = null; + } + } + + render(props, state, { loading }) { + return ( +
+
+

+ +

+
+
+
+
+
+ +

+ + + + +

+

+ +

+

+ + +

+ +
+
+ + + } + value={props.netatmoClientId} + className="form-control" + autocomplete="off" + onInput={this.updateClientId} + /> + +
+ +
+ +
+ + } + value={props.netatmoClientSecret} + className="form-control" + autocomplete="off" + onInput={this.updateClientSecret} + /> + + + + +
+
+
+ +
+ {props.notOnGladysGateway && ( +
+ + + + {props.notOnGladysGateway && props.connected && ( + + )} +
+ )} + +
+
+
+
+ ); + } +} + +export default connect('user,session,httpClient', {})(SetupTab); diff --git a/front/src/routes/integration/all/netatmo/setup-page/StateConnection.jsx b/front/src/routes/integration/all/netatmo/setup-page/StateConnection.jsx new file mode 100644 index 0000000000..01ceeeeeff --- /dev/null +++ b/front/src/routes/integration/all/netatmo/setup-page/StateConnection.jsx @@ -0,0 +1,40 @@ +import { Text, MarkupText } from 'preact-i18n'; +import { STATUS } from '../../../../../../../server/services/netatmo/lib/utils/netatmo.constants'; + +const StateConnection = props => ( +
+ {props.accessDenied && ( +

+ +

+ )} + {!props.accessDenied && + ((props.connectNetatmoStatus === STATUS.CONNECTING && ( +

+ +

+ )) || + (props.connectNetatmoStatus === STATUS.PROCESSING_TOKEN && ( +

+ +

+ )) || + (props.connected && ( +

+ +

+ )) || + (props.connectNetatmoStatus === STATUS.DISCONNECTED && ( +

+ +

+ )) || + (props.connectNetatmoStatus === STATUS.NOT_INITIALIZED && ( +

+ +

+ )))} +
+); + +export default StateConnection; diff --git a/front/src/routes/integration/all/netatmo/setup-page/index.js b/front/src/routes/integration/all/netatmo/setup-page/index.js new file mode 100644 index 0000000000..449e1a4d09 --- /dev/null +++ b/front/src/routes/integration/all/netatmo/setup-page/index.js @@ -0,0 +1,305 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import { route } from 'preact-router'; +import SetupTab from './SetupTab'; +import NetatmoPage from '../NetatmoPage'; +import withIntlAsProp from '../../../../../utils/withIntlAsProp'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../../../server/utils/constants'; +import { STATUS } from '../../../../../../../server/services/netatmo/lib/utils/netatmo.constants'; +import { RequestStatus } from '../../../../../utils/consts'; + +class NetatmoSetupPage extends Component { + getRedirectUri = async () => { + try { + const result = await this.props.httpClient.post('/api/v1/service/netatmo/connect'); + const redirectUri = `${result.authUrl}&redirect_uri=${encodeURIComponent(this.state.redirectUriNetatmoSetup)}`; + await this.setState({ + redirectUri + }); + } catch (e) { + console.error(e); + await this.setState({ errored: true }); + } + }; + + getSessionGatewayClient = async () => { + if (!this.props.session.gatewayClient) { + this.setState({ + notOnGladysGateway: true, + redirectUriNetatmoSetup: `${window.location.origin}/dashboard/integration/device/netatmo/setup` + }); + } else return; + }; + + detectCode = async () => { + if (this.props.error) { + if (this.props.error === 'access_denied' || this.props.error === 'invalid_client') { + this.props.httpClient.post('/api/v1/service/netatmo/status', { + statusType: STATUS.ERROR.CONNECTING, + message: this.props.error + }); + await this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + configured: true, + accessDenied: true, + messageAlert: this.props.error + }); + } else { + this.props.httpClient.post('/api/v1/service/netatmo/status', { + statusType: STATUS.ERROR.CONNECTING, + message: 'other_error' + }); + await this.setState({ + accessDenied: true, + messageAlert: 'other_error', + errored: true + }); + console.error('Logs error', this.props); + } + } + if (this.props.code && this.props.state) { + let successfulNewToken = false; + try { + await this.setState({ + connectNetatmoStatus: STATUS.PROCESSING_TOKEN, + connected: false, + configured: true, + errored: false + }); + const response = await this.props.httpClient.post('/api/v1/service/netatmo/token', { + codeOAuth: this.props.code, + redirectUri: this.state.redirectUriNetatmoSetup, + state: this.props.state + }); + if (response) successfulNewToken = true; + await this.props.httpClient.post('/api/v1/service/netatmo/variable/NETATMO_CONNECTED', { + value: successfulNewToken + }); + this.setState({ + connectNetatmoStatus: STATUS.CONNECTED, + connected: true, + configured: true, + errored: false + }); + setTimeout(() => { + route('/dashboard/integration/device/netatmo/setup', true); + }, 100); + } catch (e) { + console.error(e); + this.props.httpClient.post('/api/v1/service/netatmo/status', { + statusType: STATUS.PROCESSING_TOKEN, + message: 'other_error' + }); + await this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + configured: true, + errored: true + }); + } + } + }; + + saveConfiguration = async e => { + e.preventDefault(); + + try { + this.props.httpClient.post('/api/v1/service/netatmo/configuration', { + clientId: this.state.netatmoClientId, + clientSecret: this.state.netatmoClientSecret + }); + await this.setState({ + netatmoSaveSettingsStatus: RequestStatus.Success + }); + } catch (e) { + await this.setState({ + netatmoSaveSettingsStatus: RequestStatus.Error, + errored: true + }); + } + try { + await this.setState({ + connectNetatmoStatus: STATUS.CONNECTING, + connected: false, + configured: true + }); + await this.getRedirectUri(); + const redirectUri = this.state.redirectUri; + const regex = /dashboard|integration|device|netatmo|setup/; + if (redirectUri && regex.test(this.state.redirectUri)) { + window.location.href = this.state.redirectUri; + await this.setState({ + connectNetatmoStatus: RequestStatus.Success, + connected: false, + configured: true + }); + } else { + console.error('Missing redirect URL'); + await this.setState({ + connectNetatmoStatus: STATUS.ERROR.CONNECTING, + connected: false + }); + } + } catch (e) { + console.error('Error when redirecting to netatmo', e); + + await this.setState({ + connectNetatmoStatus: STATUS.ERROR.CONNECTING, + connected: false, + errored: true + }); + } + }; + + loadProps = async () => { + let configuration = {}; + try { + configuration = await this.props.httpClient.get('/api/v1/service/netatmo/config'); + } catch (e) { + console.error(e); + await this.setState({ errored: true }); + } finally { + await this.setState({ + netatmoClientId: configuration.clientId, + netatmoClientSecret: configuration.clientSecret, + clientSecretChanges: false + }); + } + }; + + loadStatus = async () => { + try { + const netatmoStatus = await this.props.httpClient.get('/api/v1/service/netatmo/status'); + await this.setState({ + connectNetatmoStatus: netatmoStatus.status, + connected: netatmoStatus.connected, + configured: netatmoStatus.configured + }); + } catch (e) { + await this.setState({ + netatmoConnectionError: RequestStatus.NetworkError, + errored: true + }); + console.error(e); + } + }; + + init = async () => { + await this.setState({ loading: true, errored: false }); + await Promise.all([this.getSessionGatewayClient(), this.detectCode()]); + await this.setState({ loading: false }); + }; + + updateStatus = async state => { + let connected = false; + let configured = false; + if ( + state.status === STATUS.CONNECTED || + state.status === STATUS.GET_DEVICES_VALUES || + state.status === STATUS.DISCOVERING_DEVICES + ) { + connected = true; + configured = true; + } else if (state.status === STATUS.NOT_INITIALIZED) { + connected = false; + configured = false; + } else { + connected = false; + configured = true; + } + await this.setState({ + connectNetatmoStatus: state.status, + connected, + configured + }); + }; + + updateStatusError = async state => { + switch (state.statusType) { + case STATUS.CONNECTING: + if (state.status !== 'other_error') { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + case STATUS.PROCESSING_TOKEN: + if (state.status === 'get_access_token_fail') { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else if (state.status === 'invalid_client') { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectNetatmoStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + } + }; + + handleStateUpdateFromChild = newState => { + this.setState(newState); + }; + + componentDidMount() { + this.init(); + this.loadProps(); + this.loadStatus(); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.NETATMO.STATUS, this.updateStatus); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.CONNECTING, this.updateStatusError); + this.props.session.dispatcher.addListener( + WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.PROCESSING_TOKEN, + this.updateStatusError + ); + } + componentWillUnmount() { + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.NETATMO.STATUS, this.updateStatus); + this.props.session.dispatcher.removeListener( + WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.CONNECTING, + this.updateStatusError + ); + this.props.session.dispatcher.removeListener( + WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.PROCESSING_TOKEN, + this.updateStatusError + ); + } + + render(props, state, { loading }) { + return ( + + + + ); + } +} + +export default withIntlAsProp(connect('user,session,httpClient', {})(NetatmoSetupPage)); diff --git a/front/src/routes/integration/all/netatmo/setup-page/style.css b/front/src/routes/integration/all/netatmo/setup-page/style.css new file mode 100644 index 0000000000..323a29bbe8 --- /dev/null +++ b/front/src/routes/integration/all/netatmo/setup-page/style.css @@ -0,0 +1,30 @@ +.highlightText { + text-decoration: underline; + font-weight: bold; +} + +.italicText { + font-style: italic; +} + +.btnTextLineSpacing { + line-height: 1.5; +} +/* Default style for large screens */ +.buttonGroup { + display: flex; + justify-content: space-between; + margin-top: 1rem; +} + +/* Style for small screens */ +@media (max-width: 768px) { + .buttonGroup { + flex-direction: column; + align-items: stretch; + } + + .buttonGroup button:not(:last-child) { + margin-bottom: 1rem; + } +} \ No newline at end of file diff --git a/front/src/routes/integration/all/netatmo/style.css b/front/src/routes/integration/all/netatmo/style.css new file mode 100644 index 0000000000..8ae3456241 --- /dev/null +++ b/front/src/routes/integration/all/netatmo/style.css @@ -0,0 +1,5 @@ +.device-image-container { + max-width: 75%; + margin: 0 auto; + display: block; +} \ No newline at end of file diff --git a/front/src/utils/consts.js b/front/src/utils/consts.js index b0d4f04e74..6f1771598a 100644 --- a/front/src/utils/consts.js +++ b/front/src/utils/consts.js @@ -74,6 +74,8 @@ export const RequestStatus = { ConflictError: 'ConflictError', ValidationError: 'ValidationError', RateLimitError: 'RateLimitError', + ServiceConnected: 'ServiceConnected', + ServiceDisconnected: 'ServiceDisconnected', ServiceNotConfigured: 'ServiceNotConfigured', GatewayNoInstanceFound: 'GatewayNoInstanceFound', UserNotAcceptedLocally: 'UserNotAcceptedLocally', diff --git a/server/services/index.js b/server/services/index.js index 359bb3701a..a0fc725dbb 100644 --- a/server/services/index.js +++ b/server/services/index.js @@ -23,5 +23,6 @@ module.exports['nextcloud-talk'] = require('./nextcloud-talk'); module.exports.tuya = require('./tuya'); module.exports.melcloud = require('./melcloud'); module.exports['node-red'] = require('./node-red'); +module.exports.netatmo = require('./netatmo'); module.exports.sonos = require('./sonos'); module.exports['zwavejs-ui'] = require('./zwavejs-ui'); diff --git a/server/services/netatmo/api/netatmo.controller.js b/server/services/netatmo/api/netatmo.controller.js new file mode 100644 index 0000000000..0e1f34f908 --- /dev/null +++ b/server/services/netatmo/api/netatmo.controller.js @@ -0,0 +1,134 @@ +const asyncMiddleware = require('../../../api/middlewares/asyncMiddleware'); + +module.exports = function NetatmoController(netatmoHandler) { + /** + * @api {get} /api/v1/service/netatmo/config Get Netatmo Configuration. + * @apiName getConfiguration + * @apiGroup Netatmo + */ + async function getConfiguration(req, res) { + const configuration = await netatmoHandler.getConfiguration(); + res.json(configuration); + } + + /** + * @api {get} /api/v1/service/netatmo/status Get Netatmo Status. + * @apiName getStatus + * @apiGroup Netatmo + */ + async function getStatus(req, res) { + const result = await netatmoHandler.getStatus(); + res.json(result); + } + + /** + * @api {post} /api/v1/service/netatmo/configuration Save Netatmo Configuration. + * @apiName saveConfiguration + * @apiGroup Netatmo + */ + async function saveConfiguration(req, res) { + const result = await netatmoHandler.saveConfiguration(req.body); + res.json({ + success: result, + }); + } + + /** + * @api {post} /api/v1/service/netatmo/status save Netatmo connection status + * @apiName saveStatus + * @apiGroup Netatmo + */ + async function saveStatus(req, res) { + const result = await netatmoHandler.saveStatus(req.body); + res.json({ + success: result, + }); + } + + /** + * @api {post} /api/v1/service/netatmo/connect Connect netatmo + * @apiName connect + * @apiGroup Netatmo + */ + async function connect(req, res) { + await netatmoHandler.getConfiguration(); + const result = await netatmoHandler.connect(); + res.json(result); + } + + /** + * @api {post} /api/v1/service/netatmo/token Retrieve access and refresh Tokens netatmo with code of return + * @apiName retrieveTokens + * @apiGroup Netatmo + */ + async function retrieveTokens(req, res) { + await netatmoHandler.getConfiguration(); + const result = await netatmoHandler.retrieveTokens(req.body); + res.json(result); + } + + /** + * @api {post} /api/v1/service/netatmo/disconnect Disconnect netatmo + * @apiName disconnect + * @apiGroup Netatmo + */ + async function disconnect(req, res) { + await netatmoHandler.disconnect(); + res.json({ + success: true, + }); + } + + /** + * @api {get} /api/v1/service/netatmo/discover Discover netatmo devices from API. + * @apiName discover + * @apiGroup Netatmo + */ + async function discover(req, res) { + let devices; + if (!netatmoHandler.discoveredDevices || req.query.refresh === 'true') { + devices = await netatmoHandler.discoverDevices(); + } else { + devices = netatmoHandler.discoveredDevices.filter((device) => { + const existInGladys = netatmoHandler.gladys.stateManager.get('deviceByExternalId', device.external_id); + return existInGladys === null; + }); + } + res.json(devices); + } + + return { + 'get /api/v1/service/netatmo/config': { + authenticated: true, + controller: asyncMiddleware(getConfiguration), + }, + 'post /api/v1/service/netatmo/configuration': { + authenticated: true, + controller: asyncMiddleware(saveConfiguration), + }, + 'get /api/v1/service/netatmo/status': { + authenticated: true, + controller: asyncMiddleware(getStatus), + }, + 'post /api/v1/service/netatmo/status': { + authenticated: true, + controller: asyncMiddleware(saveStatus), + }, + 'post /api/v1/service/netatmo/connect': { + authenticated: true, + controller: asyncMiddleware(connect), + }, + 'post /api/v1/service/netatmo/token': { + authenticated: true, + controller: asyncMiddleware(retrieveTokens), + }, + 'post /api/v1/service/netatmo/disconnect': { + authenticated: true, + controller: asyncMiddleware(disconnect), + }, + 'get /api/v1/service/netatmo/discover': { + authenticated: true, + controller: asyncMiddleware(discover), + }, + }; +}; diff --git a/server/services/netatmo/index.js b/server/services/netatmo/index.js new file mode 100644 index 0000000000..c2e9c7f116 --- /dev/null +++ b/server/services/netatmo/index.js @@ -0,0 +1,50 @@ +const logger = require('../../utils/logger'); +const netatmoController = require('./api/netatmo.controller'); + +const NetatmoHandler = require('./lib'); +const { STATUS } = require('./lib/utils/netatmo.constants'); + +module.exports = function NetatmoService(gladys, serviceId) { + const netatmoHandler = new NetatmoHandler(gladys, serviceId); + + /** + * @public + * @description This function starts service. + * @example + * gladys.services.netatmo.start(); + */ + async function start() { + logger.info('Starting Netatmo service', serviceId); + await netatmoHandler.init(); + } + + /** + * @public + * @description This function stops the service. + * @example + * gladys.services.netatmo.stop(); + */ + async function stop() { + logger.info('Stopping Netatmo service'); + await netatmoHandler.disconnect(); + } + + /** + * @public + * @description Test if Netatmo is running. + * @returns {Promise} Returns true if Netatmo is used. + * @example + * const used = await gladys.services.netatmo.isUsed(); + */ + async function isUsed() { + return netatmoHandler.status === STATUS.CONNECTED; + } + + return Object.freeze({ + start, + stop, + isUsed, + device: netatmoHandler, + controllers: netatmoController(netatmoHandler), + }); +}; diff --git a/server/services/netatmo/lib/device/netatmo.buildFeaturesCommon.js b/server/services/netatmo/lib/device/netatmo.buildFeaturesCommon.js new file mode 100644 index 0000000000..c91be72c0e --- /dev/null +++ b/server/services/netatmo/lib/device/netatmo.buildFeaturesCommon.js @@ -0,0 +1,33 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +/** + * @description Transforms Netatmo feature as Gladys feature. + * @param {string} name - Name device from Netatmo. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureBattery(device_name, 'netatmo:device_id'); + */ +function buildFeatureBattery(name, externalId) { + return { + name: `Battery - ${name}`, + external_id: `${externalId}:battery_percent`, + selector: `${externalId}:battery_percent`, + category: DEVICE_FEATURE_CATEGORIES.BATTERY, + type: DEVICE_FEATURE_TYPES.BATTERY.INTEGER, + unit: DEVICE_FEATURE_UNITS.PERCENT, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 100, + }; +} + +module.exports = { + buildFeatureBattery, +}; diff --git a/server/services/netatmo/lib/device/netatmo.buildFeaturesCommonTemp.js b/server/services/netatmo/lib/device/netatmo.buildFeaturesCommonTemp.js new file mode 100644 index 0000000000..9b32dc5d52 --- /dev/null +++ b/server/services/netatmo/lib/device/netatmo.buildFeaturesCommonTemp.js @@ -0,0 +1,58 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +/** + * @description Transforms Netatmo feature as Gladys feature. Current temperature. + * @param {string} name - Name device from Netatmo. + * @param {string} externalId - Gladys external ID. + * @param {string} featureName - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureTemperature(device_name, 'netatmo:device_id', 'temperature'); + */ +function buildFeatureTemperature(name, externalId, featureName) { + return { + name: `Temperature - ${name}`, + external_id: `${externalId}:${featureName}`, + selector: `${externalId}:${featureName}`, + category: DEVICE_FEATURE_CATEGORIES.TEMPERATURE_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.DECIMAL, + unit: DEVICE_FEATURE_UNITS.CELSIUS, + read_only: true, + keep_history: true, + has_feedback: false, + min: -10, + max: 50, + }; +} + +/** + * @description Transforms Netatmo feature as Gladys feature. + * @param {string} name - Name device from Netatmo. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureOpenWindow(device_name, 'netatmo:device_id'); + */ +function buildFeatureOpenWindow(name, externalId) { + return { + name: `Detecting open window - ${name}`, + external_id: `${externalId}:open_window`, + selector: `${externalId}:open_window`, + category: DEVICE_FEATURE_CATEGORIES.OPENING_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.BINARY, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 1, + }; +} + +module.exports = { + buildFeatureTemperature, + buildFeatureOpenWindow, +}; diff --git a/server/services/netatmo/lib/device/netatmo.buildFeaturesSignal.js b/server/services/netatmo/lib/device/netatmo.buildFeaturesSignal.js new file mode 100644 index 0000000000..6b65c35249 --- /dev/null +++ b/server/services/netatmo/lib/device/netatmo.buildFeaturesSignal.js @@ -0,0 +1,54 @@ +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); + +/** + * @description Transforms Netatmo feature as Gladys feature. + * Signal RF strength (no signal, weak, average, good or excellent). + * @param {string} name - Name device from Netatmo. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureRfStrength(device_name, 'netatmo:device_id'); + */ +function buildFeatureRfStrength(name, externalId) { + return { + name: `Link RF quality - ${name}`, + external_id: `${externalId}:rf_strength`, + selector: `${externalId}:rf_strength`, + category: DEVICE_FEATURE_CATEGORIES.SIGNAL, + type: DEVICE_FEATURE_TYPES.SIGNAL.QUALITY, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 100, + }; +} + +/** + * @description Transforms Netatmo feature as Gladys feature. + * Signal wifi strength (no signal, weak, average, good or excellent). + * @param {string} name - Name device from Netatmo. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureWifiStrength(device_name, 'netatmo:device_id'); + */ +function buildFeatureWifiStrength(name, externalId) { + return { + name: `Link Wifi quality - ${name}`, + external_id: `${externalId}:wifi_strength`, + selector: `${externalId}:wifi_strength`, + category: DEVICE_FEATURE_CATEGORIES.SIGNAL, + type: DEVICE_FEATURE_TYPES.SIGNAL.QUALITY, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 100, + }; +} + +module.exports = { + buildFeatureRfStrength, + buildFeatureWifiStrength, +}; diff --git a/server/services/netatmo/lib/device/netatmo.buildFeaturesSpecifEnergy.js b/server/services/netatmo/lib/device/netatmo.buildFeaturesSpecifEnergy.js new file mode 100644 index 0000000000..1b308ec5f8 --- /dev/null +++ b/server/services/netatmo/lib/device/netatmo.buildFeaturesSpecifEnergy.js @@ -0,0 +1,81 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +/** + * @description Transforms Netatmo feature as Gladys feature. Temperature setpoint. + * @param {string} name - Name device from Netatmo. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureThermSetpointTemperature(device_name, 'netatmo:device_id'); + */ +function buildFeatureThermSetpointTemperature(name, externalId) { + return { + name: `Setpoint temperature - ${name}`, + external_id: `${externalId}:therm_setpoint_temperature`, + selector: `${externalId}:therm_setpoint_temperature`, + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + unit: DEVICE_FEATURE_UNITS.CELSIUS, + read_only: false, + keep_history: true, + has_feedback: false, + min: 5, + max: 30, + }; +} + +/** + * @description Transforms Netatmo feature as Gladys feature. Heating boiler status. + * @param {string} name - Name device from Netatmo. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureHeatingBoilerStatus(device_name, 'netatmo:device_id'); + */ +function buildFeatureBoilerStatus(name, externalId) { + return { + name: `Boiler status - ${name}`, + external_id: `${externalId}:boiler_status`, + selector: `${externalId}:boiler_status`, + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 1, + }; +} + +/** + * @description Transforms Netatmo feature as Gladys feature. Plug connected boiler. + * @param {string} name - Name device from Netatmo. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeaturePlugConnectedBoiler(device_name, 'netatmo:device_id'); + */ +function buildFeaturePlugConnectedBoiler(name, externalId) { + return { + name: `${name} connected boiler`, + external_id: `${externalId}:plug_connected_boiler`, + selector: `${externalId}:plug_connected_boiler`, + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 1, + }; +} + +module.exports = { + buildFeatureThermSetpointTemperature, + buildFeatureBoilerStatus, + buildFeaturePlugConnectedBoiler, +}; diff --git a/server/services/netatmo/lib/device/netatmo.convertDevice.js b/server/services/netatmo/lib/device/netatmo.convertDevice.js new file mode 100644 index 0000000000..6ea334ae3a --- /dev/null +++ b/server/services/netatmo/lib/device/netatmo.convertDevice.js @@ -0,0 +1,84 @@ +const { buildFeatureTemperature, buildFeatureOpenWindow } = require('./netatmo.buildFeaturesCommonTemp'); +const logger = require('../../../../utils/logger'); +const { SUPPORTED_MODULE_TYPE, PARAMS } = require('../utils/netatmo.constants'); +const { buildFeatureBattery } = require('./netatmo.buildFeaturesCommon'); +const { buildFeatureRfStrength, buildFeatureWifiStrength } = require('./netatmo.buildFeaturesSignal'); +const { + buildFeatureThermSetpointTemperature, + buildFeatureBoilerStatus, + buildFeaturePlugConnectedBoiler, +} = require('./netatmo.buildFeaturesSpecifEnergy'); + +/** + * @description Transform Netatmo device to Gladys device. + * @param {object} netatmoDevice - Netatmo device. + * @returns {object} Gladys device. + * @example + * netatmo.convertDevice({ ... }); + */ +function convertDevice(netatmoDevice) { + const { home: homeId, name, type: model, id, room = {}, plug = {} } = netatmoDevice; + const externalId = `netatmo:${id}`; + logger.debug(`Netatmo convert device "${name}, ${model}"`); + const features = []; + let params = []; + switch (model) { + case SUPPORTED_MODULE_TYPE.THERMOSTAT: { + /* features common */ + features.push(buildFeatureBattery(name, externalId)); + /* features common Netatmo Energy */ + features.push(buildFeatureTemperature(name, externalId, 'temperature')); + features.push(buildFeatureTemperature(`room ${room.name}`, externalId, 'therm_measured_temperature')); + features.push(buildFeatureThermSetpointTemperature(name, externalId)); + features.push(buildFeatureOpenWindow(name, externalId)); + /* features common modules RF */ + features.push(buildFeatureRfStrength(name, externalId)); + /* features specific Energy */ + features.push(buildFeatureBoilerStatus(name, externalId)); + /* params */ + params = [ + { name: PARAMS.PLUG_ID, value: plug.id }, + { name: PARAMS.PLUG_NAME, value: plug.name }, + ]; + break; + } + case SUPPORTED_MODULE_TYPE.PLUG: { + const modulesBridged = netatmoDevice.modules_bridged || []; + /* features common modules RF */ + features.push(buildFeatureRfStrength(name, externalId)); + /* features common modules WiFi */ + features.push(buildFeatureWifiStrength(name, externalId)); + /* features specif Plugs */ + features.push(buildFeaturePlugConnectedBoiler(name, externalId)); + /* params */ + params = [{ name: PARAMS.MODULES_BRIDGE_ID, value: JSON.stringify(modulesBridged) }]; + break; + } + default: + break; + } + /* params common to all devices features */ + params.push({ name: PARAMS.HOME_ID, value: homeId }); + if (room.id) { + params.push({ name: PARAMS.ROOM_ID, value: room.id }, { name: PARAMS.ROOM_NAME, value: room.name }); + } + const device = { + name, + external_id: externalId, + selector: externalId, + model, + service_id: this.serviceId, + should_poll: false, + features: features.filter((feature) => feature), + params: params.filter((param) => param), + }; + if (netatmoDevice.not_handled) { + device.not_handled = true; + } + logger.info(`Netatmo "${name}, ${model}" device converted`); + return device; +} + +module.exports = { + convertDevice, +}; diff --git a/server/services/netatmo/lib/device/netatmo.deviceMapping.js b/server/services/netatmo/lib/device/netatmo.deviceMapping.js new file mode 100644 index 0000000000..3400c807b1 --- /dev/null +++ b/server/services/netatmo/lib/device/netatmo.deviceMapping.js @@ -0,0 +1,56 @@ +const { DEVICE_FEATURE_TYPES, DEVICE_FEATURE_CATEGORIES } = require('../../../../utils/constants'); + +const writeValues = { + [DEVICE_FEATURE_CATEGORIES.THERMOSTAT]: { + /* therm_setpoint_temperature: 14 */ + [DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE]: (valueFromDevice) => { + return valueFromDevice; + }, + }, +}; + +const readValues = { + [DEVICE_FEATURE_CATEGORIES.THERMOSTAT]: { + [DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE]: (valueFromDevice) => { + return valueFromDevice; + }, + }, + [DEVICE_FEATURE_CATEGORIES.SWITCH]: { + /* plug_connected_boiler: 1 */ + /* boiler_status: true */ + [DEVICE_FEATURE_TYPES.SWITCH.BINARY]: (valueFromDevice) => { + const valueToGladys = valueFromDevice === true || valueFromDevice === 1 ? 1 : 0; + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.BATTERY]: { + /* battery_percent: 76 */ + [DEVICE_FEATURE_TYPES.BATTERY.INTEGER]: (valueFromDevice) => { + const valueToGladys = parseInt(valueFromDevice, 10); + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.TEMPERATURE_SENSOR]: { + /* temperature: 20.5 */ + [DEVICE_FEATURE_TYPES.SENSOR.DECIMAL]: (valueFromDevice) => { + return valueFromDevice; + }, + }, + [DEVICE_FEATURE_CATEGORIES.SIGNAL]: { + /* rf_strength: 76 */ + /* wifi_strength: 76 */ + [DEVICE_FEATURE_TYPES.SIGNAL.QUALITY]: (valueFromDevice) => { + const valueToGladys = parseInt(valueFromDevice, 10); + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.OPENING_SENSOR]: { + /* room.open_window: false */ + [DEVICE_FEATURE_TYPES.SENSOR.BINARY]: (valueFromDevice) => { + const valueToGladys = valueFromDevice === true || valueFromDevice === 1 ? 1 : 0; + return valueToGladys; + }, + }, +}; + +module.exports = { readValues, writeValues }; diff --git a/server/services/netatmo/lib/device/netatmo.updateNAPlug.js b/server/services/netatmo/lib/device/netatmo.updateNAPlug.js new file mode 100644 index 0000000000..ffa1a79dea --- /dev/null +++ b/server/services/netatmo/lib/device/netatmo.updateNAPlug.js @@ -0,0 +1,53 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { readValues } = require('./netatmo.deviceMapping'); + +/** + * @description Save values of NAPlug. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} deviceNetatmo - Device object coming from the Netatmo API. + * @param {string} externalId - Device identifier in gladys. + * @example updateNAPlug(deviceGladys, deviceNetatmo, externalId); + */ +async function updateNAPlug(deviceGladys, deviceNetatmo, externalId) { + try { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:rf_strength`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `${externalId}:rf_strength`, + state: readValues[feature.category][feature.type](deviceNetatmo.rf_strength), + }); + }); + + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:wifi_strength`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `${externalId}:wifi_strength`, + state: readValues[feature.category][feature.type](deviceNetatmo.wifi_strength), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:plug_connected_boiler`) + .forEach((feature) => { + if (typeof deviceNetatmo.plug_connected_boiler !== 'undefined') { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `${externalId}:plug_connected_boiler`, + state: readValues[feature.category][feature.type](deviceNetatmo.plug_connected_boiler), + }); + } else { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `${externalId}:plug_connected_boiler`, + state: readValues[feature.category][feature.type](false), + }); + } + }); + } catch (e) { + logger.error('deviceGladys NAPlug: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateNAPlug, +}; diff --git a/server/services/netatmo/lib/device/netatmo.updateNATherm1.js b/server/services/netatmo/lib/device/netatmo.updateNATherm1.js new file mode 100644 index 0000000000..c44d81f347 --- /dev/null +++ b/server/services/netatmo/lib/device/netatmo.updateNATherm1.js @@ -0,0 +1,78 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { readValues } = require('./netatmo.deviceMapping'); + +/** + * @description Save values of Thermostats NATherm1. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} deviceNetatmo - Device object coming from the Netatmo API. + * @param {string} externalId - Device identifier in gladys. + * @example updateNATherm1(deviceGladys, deviceNetatmo, externalId); + */ +async function updateNATherm1(deviceGladys, deviceNetatmo, externalId) { + const { measured, room } = deviceNetatmo; + try { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:battery_percent`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `${externalId}:battery_percent`, + state: readValues[feature.category][feature.type](deviceNetatmo.battery_percent), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:temperature`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `${externalId}:temperature`, + state: readValues[feature.category][feature.type](measured.temperature), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:therm_measured_temperature`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `${externalId}:therm_measured_temperature`, + state: readValues[feature.category][feature.type](room.therm_measured_temperature), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:therm_setpoint_temperature`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `${externalId}:therm_setpoint_temperature`, + state: readValues[feature.category][feature.type](room.therm_setpoint_temperature), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:open_window`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `${externalId}:open_window`, + state: readValues[feature.category][feature.type](room.open_window), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:rf_strength`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `${externalId}:rf_strength`, + state: readValues[feature.category][feature.type](deviceNetatmo.rf_strength), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:boiler_status`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `${externalId}:boiler_status`, + state: readValues[feature.category][feature.type](deviceNetatmo.boiler_status), + }); + }); + } catch (e) { + logger.error('deviceGladys NATherm1: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateNATherm1, +}; diff --git a/server/services/netatmo/lib/index.js b/server/services/netatmo/lib/index.js new file mode 100644 index 0000000000..3678e2d23f --- /dev/null +++ b/server/services/netatmo/lib/index.js @@ -0,0 +1,70 @@ +const { init } = require('./netatmo.init'); +const { connect } = require('./netatmo.connect'); +const { retrieveTokens } = require('./netatmo.retrieveTokens'); +const { disconnect } = require('./netatmo.disconnect'); +const { setTokens } = require('./netatmo.setTokens'); +const { getAccessToken } = require('./netatmo.getAccessToken'); +const { getRefreshToken } = require('./netatmo.getRefreshToken'); +const { refreshingTokens } = require('./netatmo.refreshingTokens'); +const { getConfiguration } = require('./netatmo.getConfiguration'); +const { getStatus } = require('./netatmo.getStatus'); +const { saveStatus } = require('./netatmo.saveStatus'); +const { saveConfiguration } = require('./netatmo.saveConfiguration'); +const { discoverDevices } = require('./netatmo.discoverDevices'); +const { loadDevices } = require('./netatmo.loadDevices'); +const { loadDeviceDetails } = require('./netatmo.loadDeviceDetails'); +const { loadThermostatDetails } = require('./netatmo.loadThermostatDetails'); +const { pollRefreshingToken } = require('./netatmo.pollRefreshingToken'); +const { pollRefreshingValues } = require('./netatmo.pollRefreshingValues'); +const { setValue } = require('./netatmo.setValue'); +const { updateValues } = require('./netatmo.updateValues'); +const { updateNAPlug } = require('./device/netatmo.updateNAPlug'); +const { updateNATherm1 } = require('./device/netatmo.updateNATherm1'); + +const { STATUS, SCOPES } = require('./utils/netatmo.constants'); +const buildScopesConfig = require('./utils/netatmo.buildScopesConfig'); + +const NetatmoHandler = function NetatmoHandler(gladys, serviceId) { + this.gladys = gladys; + this.serviceId = serviceId; + this.configuration = { + clientId: null, + clientSecret: null, + scopes: buildScopesConfig(SCOPES), + }; + this.configured = false; + this.connected = false; + this.redirectUri = null; + this.accessToken = null; + this.refreshToken = null; + this.expireInToken = null; + this.stateGetAccessToken = null; + this.status = STATUS.NOT_INITIALIZED; + this.pollRefreshToken = undefined; + this.pollRefreshValues = undefined; +}; + +NetatmoHandler.prototype.init = init; +NetatmoHandler.prototype.connect = connect; +NetatmoHandler.prototype.retrieveTokens = retrieveTokens; +NetatmoHandler.prototype.disconnect = disconnect; +NetatmoHandler.prototype.setTokens = setTokens; +NetatmoHandler.prototype.getStatus = getStatus; +NetatmoHandler.prototype.saveStatus = saveStatus; +NetatmoHandler.prototype.getAccessToken = getAccessToken; +NetatmoHandler.prototype.getRefreshToken = getRefreshToken; +NetatmoHandler.prototype.refreshingTokens = refreshingTokens; +NetatmoHandler.prototype.getConfiguration = getConfiguration; +NetatmoHandler.prototype.saveConfiguration = saveConfiguration; +NetatmoHandler.prototype.discoverDevices = discoverDevices; +NetatmoHandler.prototype.loadDevices = loadDevices; +NetatmoHandler.prototype.loadDeviceDetails = loadDeviceDetails; +NetatmoHandler.prototype.loadThermostatDetails = loadThermostatDetails; +NetatmoHandler.prototype.pollRefreshingValues = pollRefreshingValues; +NetatmoHandler.prototype.pollRefreshingToken = pollRefreshingToken; +NetatmoHandler.prototype.setValue = setValue; +NetatmoHandler.prototype.updateValues = updateValues; +NetatmoHandler.prototype.updateNAPlug = updateNAPlug; +NetatmoHandler.prototype.updateNATherm1 = updateNATherm1; + +module.exports = NetatmoHandler; diff --git a/server/services/netatmo/lib/netatmo.connect.js b/server/services/netatmo/lib/netatmo.connect.js new file mode 100644 index 0000000000..82285d2d30 --- /dev/null +++ b/server/services/netatmo/lib/netatmo.connect.js @@ -0,0 +1,34 @@ +const crypto = require('crypto'); + +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); + +const { STATUS, API } = require('./utils/netatmo.constants'); + +/** + * @description Connect to Netatmo and getting code to get access tokens. + * @returns {Promise} Netatmo access token. + * @example + * connect(); + */ +async function connect() { + const { clientId, clientSecret, scopes } = this.configuration; + if (!clientId || !clientSecret) { + await this.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + throw new ServiceNotConfiguredError('Netatmo is not configured.'); + } + await this.saveStatus({ statusType: STATUS.CONNECTING, message: null }); + logger.debug('Connecting to Netatmo...'); + + this.stateGetAccessToken = crypto.randomBytes(16).toString('hex'); + const scopeValues = Object.values(scopes).join(' '); + this.redirectUri = `${API.OAUTH2}?client_id=${clientId}&scope=${encodeURIComponent(scopeValues)}&state=${ + this.stateGetAccessToken + }`; + this.configured = true; + return { authUrl: this.redirectUri, state: this.stateGetAccessToken }; +} + +module.exports = { + connect, +}; diff --git a/server/services/netatmo/lib/netatmo.disconnect.js b/server/services/netatmo/lib/netatmo.disconnect.js new file mode 100644 index 0000000000..d9578f9f8a --- /dev/null +++ b/server/services/netatmo/lib/netatmo.disconnect.js @@ -0,0 +1,24 @@ +const logger = require('../../../utils/logger'); +const { STATUS } = require('./utils/netatmo.constants'); + +/** + * @description Disconnects service and dependencies. + * @example + * disconnect(); + */ +function disconnect() { + logger.debug('Disonnecting from Netatmo...'); + this.saveStatus({ statusType: STATUS.DISCONNECTING, message: null }); + const tokens = { + accessToken: '', + refreshToken: '', + expireIn: 0, + }; + this.setTokens(tokens); + this.saveStatus({ statusType: STATUS.DISCONNECTED, message: null }); + logger.debug('Netatmo disconnected'); +} + +module.exports = { + disconnect, +}; diff --git a/server/services/netatmo/lib/netatmo.discoverDevices.js b/server/services/netatmo/lib/netatmo.discoverDevices.js new file mode 100644 index 0000000000..3b504ec6ee --- /dev/null +++ b/server/services/netatmo/lib/netatmo.discoverDevices.js @@ -0,0 +1,49 @@ +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); +const { STATUS } = require('./utils/netatmo.constants'); +const { convertDevice } = require('./device/netatmo.convertDevice'); + +/** + * @description Discover Netatmo cloud devices. + * @returns {Promise} List of discovered devices;. + * @example + * await discoverDevices(); + */ +async function discoverDevices() { + logger.debug('Looking for Netatmo devices...'); + if (this.status !== STATUS.CONNECTED) { + this.saveStatus({ statusType: this.status, message: null }); + throw new ServiceNotConfiguredError('Unable to discover Netatmo devices until service is not well configured'); + } + this.discoveredDevices = []; + await this.saveStatus({ statusType: STATUS.DISCOVERING_DEVICES, message: null }); + + let devicesNetatmo = []; + try { + devicesNetatmo = await this.loadDevices(); + logger.info(`${devicesNetatmo.length} Netatmo devices found`); + } catch (e) { + logger.error('Unable to load Netatmo devices', e); + } + if (devicesNetatmo.length > 0) { + this.discoveredDevices = devicesNetatmo.map((device) => ({ + ...convertDevice(device), + service_id: this.serviceId, + deviceNetatmo: device, + })); + const discoveredDevices = this.discoveredDevices.filter((device) => { + const existInGladys = this.gladys.stateManager.get('deviceByExternalId', device.external_id); + return existInGladys === null; + }); + this.saveStatus({ statusType: STATUS.CONNECTED, message: null }); + logger.debug(`${discoveredDevices.length} new Netatmo devices found`); + return discoveredDevices; + } + this.saveStatus({ statusType: STATUS.CONNECTED, message: null }); + logger.debug('No devices found'); + return []; +} + +module.exports = { + discoverDevices, +}; diff --git a/server/services/netatmo/lib/netatmo.getAccessToken.js b/server/services/netatmo/lib/netatmo.getAccessToken.js new file mode 100644 index 0000000000..84acd98e45 --- /dev/null +++ b/server/services/netatmo/lib/netatmo.getAccessToken.js @@ -0,0 +1,36 @@ +const { GLADYS_VARIABLES, STATUS } = require('./utils/netatmo.constants'); +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); + +/** + * @description Netatmo get access token. + * @returns {Promise} Netatmo access token. + * @example + * await netatmo.getAccessToken(); + */ +async function getAccessToken() { + logger.debug('Loading Netatmo access token...'); + const { serviceId } = this; + try { + this.accessToken = await this.gladys.variable.getValue(GLADYS_VARIABLES.ACCESS_TOKEN, serviceId); + if (!this.accessToken || this.accessToken === '') { + const tokens = { + accessToken: '', + refreshToken: '', + expireIn: '', + }; + await this.setTokens(tokens); + await this.saveStatus({ statusType: STATUS.DISCONNECTED, message: null }); + return undefined; + } + logger.debug(`Netatmo access token well loaded`); + return this.accessToken; + } catch (e) { + this.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + throw new ServiceNotConfiguredError('Netatmo is not configured.'); + } +} + +module.exports = { + getAccessToken, +}; diff --git a/server/services/netatmo/lib/netatmo.getConfiguration.js b/server/services/netatmo/lib/netatmo.getConfiguration.js new file mode 100644 index 0000000000..33006210ec --- /dev/null +++ b/server/services/netatmo/lib/netatmo.getConfiguration.js @@ -0,0 +1,27 @@ +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); +const logger = require('../../../utils/logger'); +const { GLADYS_VARIABLES, STATUS } = require('./utils/netatmo.constants'); + +/** + * @description Loads Netatmo stored configuration. + * @returns {Promise} Netatmo configuration. + * @example + * await getConfiguration(); + */ +async function getConfiguration() { + logger.debug('Loading Netatmo configuration...'); + const { serviceId } = this; + try { + this.configuration.clientId = await this.gladys.variable.getValue(GLADYS_VARIABLES.CLIENT_ID, serviceId); + this.configuration.clientSecret = await this.gladys.variable.getValue(GLADYS_VARIABLES.CLIENT_SECRET, serviceId); + logger.debug(`Netatmo configuration get: clientId='${this.configuration.clientId}'`); + return this.configuration; + } catch (e) { + this.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + throw new ServiceNotConfiguredError('Netatmo is not configured.'); + } +} + +module.exports = { + getConfiguration, +}; diff --git a/server/services/netatmo/lib/netatmo.getRefreshToken.js b/server/services/netatmo/lib/netatmo.getRefreshToken.js new file mode 100644 index 0000000000..f6fb6fa1f3 --- /dev/null +++ b/server/services/netatmo/lib/netatmo.getRefreshToken.js @@ -0,0 +1,37 @@ +const { GLADYS_VARIABLES, STATUS } = require('./utils/netatmo.constants'); +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); + +/** + * @description Netatmo get refresh token method. + * @returns {Promise} Netatmo refresh token. + * @example + * await netatmo.getRefreshToken(); + */ +async function getRefreshToken() { + logger.debug('Loading Netatmo refresh token...'); + const { serviceId } = this; + try { + this.refreshToken = await this.gladys.variable.getValue(GLADYS_VARIABLES.REFRESH_TOKEN, serviceId); + this.expireInToken = await this.gladys.variable.getValue(GLADYS_VARIABLES.EXPIRE_IN_TOKEN, serviceId); + if (!this.refreshToken) { + const tokens = { + accessToken: '', + refreshToken: '', + expireIn: '', + }; + await this.setTokens(tokens); + await this.saveStatus({ statusType: STATUS.DISCONNECTED, message: null }); + return undefined; + } + logger.debug(`Netatmo refresh token well loaded`); + return this.refreshToken; + } catch (e) { + this.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + throw new ServiceNotConfiguredError('Netatmo is not configured.'); + } +} + +module.exports = { + getRefreshToken, +}; diff --git a/server/services/netatmo/lib/netatmo.getStatus.js b/server/services/netatmo/lib/netatmo.getStatus.js new file mode 100644 index 0000000000..7c498c1159 --- /dev/null +++ b/server/services/netatmo/lib/netatmo.getStatus.js @@ -0,0 +1,18 @@ +/** + * @description Get Netatmo status. + * @returns {object} Current Netatmo network status. + * @example + * status(); + */ +function getStatus() { + const netatmoStatus = { + configured: this.configured, + connected: this.connected, + status: this.status, + }; + return netatmoStatus; +} + +module.exports = { + getStatus, +}; diff --git a/server/services/netatmo/lib/netatmo.init.js b/server/services/netatmo/lib/netatmo.init.js new file mode 100644 index 0000000000..a28c07ea56 --- /dev/null +++ b/server/services/netatmo/lib/netatmo.init.js @@ -0,0 +1,19 @@ +/** + * @description Initialize service with properties and connect to devices. + * @example netatmo.init(); + */ +async function init() { + await this.getConfiguration(); + this.configured = true; + await this.getAccessToken(); + await this.getRefreshToken(); + const response = await this.refreshingTokens(); + if (response.success) { + await this.pollRefreshingToken(); + await this.pollRefreshingValues(); + } +} + +module.exports = { + init, +}; diff --git a/server/services/netatmo/lib/netatmo.loadDeviceDetails.js b/server/services/netatmo/lib/netatmo.loadDeviceDetails.js new file mode 100644 index 0000000000..ac36411dd2 --- /dev/null +++ b/server/services/netatmo/lib/netatmo.loadDeviceDetails.js @@ -0,0 +1,139 @@ +const { default: axios } = require('axios'); +const logger = require('../../../utils/logger'); +const { API, SUPPORTED_MODULE_TYPE, SUPPORTED_CATEGORY_TYPE } = require('./utils/netatmo.constants'); + +/** + * @description Discover Netatmo cloud devices. + * @param {object} homeData - House. + * @returns {Promise} List of discovered devices. + * @example + * await loadDevicesDetails(); + */ +async function loadDeviceDetails(homeData) { + const { rooms: roomsHomeData, modules: modulesHomeData, id: homeId } = homeData; + + logger.debug('loading devices details in home id: ', homeId, '...'); + const paramsForm = { + home_id: homeId, + }; + try { + const responseGetHomestatus = await axios({ + url: `${API.HOMESTATUS}`, + method: 'get', + headers: { accept: API.HEADER.ACCEPT, Authorization: `Bearer ${this.accessToken}` }, + data: paramsForm, + }); + const { body: bodyGetHomestatus, status: statusGetHomestatus } = responseGetHomestatus.data; + const { rooms: roomsHomestatus, modules: modulesHomestatus } = bodyGetHomestatus.home; + let listDevices = []; + if (statusGetHomestatus === 'ok') { + let thermostats; + let modulesThermostat = []; + if ( + modulesHomeData.find((moduleHomeData) => moduleHomeData.type === SUPPORTED_MODULE_TYPE.THERMOSTAT) !== undefined + ) { + const deviceThermostats = await this.loadThermostatDetails(); + thermostats = deviceThermostats.thermostats; + modulesThermostat = deviceThermostats.modules; + } + if (modulesHomestatus) { + listDevices = modulesHomestatus + .map((module) => { + let moduleSupported = false; + let categoryAPI; + let device; + let plugThermostat; + switch (module.type) { + case SUPPORTED_MODULE_TYPE.THERMOSTAT: + moduleSupported = true; + if (thermostats && modulesThermostat) { + device = modulesThermostat.find( + // eslint-disable-next-line no-underscore-dangle + (moduleThermostat) => moduleThermostat._id === module.id, + ); + plugThermostat = thermostats + .map((thermostat) => { + const { modules, ...rest } = thermostat; + return rest; + }) + // eslint-disable-next-line no-underscore-dangle + .find((thermostat) => thermostat._id === module.bridge); + } + categoryAPI = SUPPORTED_CATEGORY_TYPE.ENERGY; + break; + case SUPPORTED_MODULE_TYPE.PLUG: + if (thermostats) { + device = thermostats + .map((thermostat) => { + const { modules, ...rest } = thermostat; + return rest; + }) + // eslint-disable-next-line no-underscore-dangle + .find((thermostat) => thermostat._id === module.id); + } + moduleSupported = true; + categoryAPI = SUPPORTED_CATEGORY_TYPE.ENERGY; + break; + default: + moduleSupported = false; + break; + } + const moduleHomeData = modulesHomeData.find((mod) => mod.id === module.id); + const roomDevice = { + ...roomsHomeData.find((roomHomeData) => roomHomeData.id === moduleHomeData.room_id), + ...roomsHomestatus.find((room) => room.id === moduleHomeData.room_id), + }; + const plugDevice = { + ...modulesHomeData.find((mod) => mod.id === module.bridge), + ...modulesHomestatus.find((modulePlug) => modulePlug.id === module.bridge), + ...plugThermostat, + }; + const plug = Object.keys(plugDevice).length === 0 ? undefined : plugDevice; + if (moduleSupported) { + const deviceSupported = { + ...module, + ...moduleHomeData, + ...device, + home: homeId, + room: roomDevice, + plug, + categoryAPI, + }; + return deviceSupported; + } + const deviceNotSupported = { + ...module, + ...moduleHomeData, + ...device, + home: homeId, + room: roomDevice, + plug, + not_handled: true, + }; + return deviceNotSupported; + }) + .filter((item) => item !== undefined); + } else { + listDevices = undefined; + } + logger.debug('Devices details loaded in home: ', homeId); + } else { + logger.warn('Status load devices not ok: ', statusGetHomestatus); + } + return listDevices; + } catch (e) { + logger.error( + 'Error getting devices details - error: ', + e, + ' - status error: ', + e.status, + ' data error: ', + e.response.data.error, + ); + return undefined; + } +} + +module.exports = { + loadDeviceDetails, +}; diff --git a/server/services/netatmo/lib/netatmo.loadDevices.js b/server/services/netatmo/lib/netatmo.loadDevices.js new file mode 100644 index 0000000000..c89eee7306 --- /dev/null +++ b/server/services/netatmo/lib/netatmo.loadDevices.js @@ -0,0 +1,47 @@ +const Promise = require('bluebird'); +const { default: axios } = require('axios'); +const logger = require('../../../utils/logger'); +const { API } = require('./utils/netatmo.constants'); + +/** + * @description Discover Netatmo cloud devices. + * @returns {Promise} List of discovered devices. + * @example + * await loadDevices(); + */ +async function loadDevices() { + try { + const responsePage = await axios({ + url: API.HOMESDATA, + method: 'get', + headers: { accept: API.HEADER.ACCEPT, Authorization: `Bearer ${this.accessToken}` }, + }); + const { body, status } = responsePage.data; + const { homes } = body; + let listHomeDevices = []; + if (status === 'ok') { + const results = await Promise.map( + homes, + async (home) => { + const { modules } = home; + if (modules) { + return this.loadDeviceDetails(home); + } + return undefined; + }, + { concurrency: 2 }, + ); + listHomeDevices = results.filter((device) => device !== undefined).flat(); + } + logger.debug(`${listHomeDevices.length} Netatmo devices loaded`); + logger.info(`Netatmo devices not supported : ${listHomeDevices.filter((device) => device.not_handled).length}`); + return listHomeDevices; + } catch (e) { + logger.error('e.status: ', e.status, 'e.data.error', e.response.data.error); + return undefined; + } +} + +module.exports = { + loadDevices, +}; diff --git a/server/services/netatmo/lib/netatmo.loadThermostatDetails.js b/server/services/netatmo/lib/netatmo.loadThermostatDetails.js new file mode 100644 index 0000000000..3d39816ac2 --- /dev/null +++ b/server/services/netatmo/lib/netatmo.loadThermostatDetails.js @@ -0,0 +1,43 @@ +const { default: axios } = require('axios'); +const logger = require('../../../utils/logger'); +const { API } = require('./utils/netatmo.constants'); + +/** + * @description Discover Netatmo cloud thermostats. + * @returns {Promise} List of discovered thermostats and modules. + * @example + * await loadThermostatDetails(); + */ +async function loadThermostatDetails() { + logger.debug('loading Thermostats details...'); + let thermostats; + const modules = []; + try { + const responseGetThermostat = await axios({ + url: API.GET_THERMOSTATS, + method: 'get', + headers: { accept: API.HEADER.ACCEPT, Authorization: `Bearer ${this.accessToken}` }, + }); + const { body: bodyGetThermostat, status: statusGetThermostat } = responseGetThermostat.data; + thermostats = bodyGetThermostat.devices; + if (statusGetThermostat === 'ok') { + thermostats.forEach((thermostat) => { + modules.push(...thermostat.modules); + }); + } + logger.debug('Thermostats details loaded in home'); + return { thermostats, modules }; + } catch (e) { + logger.error( + 'Error getting thermostats details - status error: ', + e.statusGetThermostat, + ' data error: ', + e.response.data.error, + ); + return { thermostats: undefined, modules: undefined }; + } +} + +module.exports = { + loadThermostatDetails, +}; diff --git a/server/services/netatmo/lib/netatmo.pollRefreshingToken.js b/server/services/netatmo/lib/netatmo.pollRefreshingToken.js new file mode 100644 index 0000000000..70ee045f9d --- /dev/null +++ b/server/services/netatmo/lib/netatmo.pollRefreshingToken.js @@ -0,0 +1,29 @@ +const logger = require('../../../utils/logger'); + +/** + * @description Poll refreshing Token values of an Netatmo device. + * @example refreshNetatmoTokens(); + */ +async function refreshNetatmoTokens() { + const { expireInToken } = this; + await this.refreshingTokens(); + if (this.expireInToken !== expireInToken) { + logger.warn(`New expiration access_token : ${this.expireInToken}ms `); + clearInterval(this.pollRefreshToken); + await this.pollRefreshingToken(); + } +} + +/** + * @description Poll refreshing Token values of an Netatmo device. + * @example pollRefreshingToken(); + */ +function pollRefreshingToken() { + if (this.expireInToken > 0) { + this.pollRefreshToken = setInterval(refreshNetatmoTokens.bind(this), this.expireInToken * 1000); + } +} + +module.exports = { + pollRefreshingToken, +}; diff --git a/server/services/netatmo/lib/netatmo.pollRefreshingValues.js b/server/services/netatmo/lib/netatmo.pollRefreshingValues.js new file mode 100644 index 0000000000..5e80a5d4ae --- /dev/null +++ b/server/services/netatmo/lib/netatmo.pollRefreshingValues.js @@ -0,0 +1,50 @@ +const Promise = require('bluebird'); +const { STATUS } = require('./utils/netatmo.constants'); +const logger = require('../../../utils/logger'); + +/** + * @description Poll values of Netatmo devices. + * @example refreshNetatmoValues(); + */ +async function refreshNetatmoValues() { + logger.debug('Looking for Netatmo devices values...'); + await this.saveStatus({ statusType: STATUS.GET_DEVICES_VALUES, message: null }); + + let devicesNetatmo = []; + try { + devicesNetatmo = await this.loadDevices(); + } catch (e) { + await this.saveStatus({ + statusType: STATUS.ERROR.GET_DEVICES_VALUES, + message: 'get_devices_value_fail', + }); + logger.error('Unable to load Netatmo devices', e); + } + await Promise.map( + devicesNetatmo, + async (device) => { + const externalId = `netatmo:${device.id}`; + const deviceExistInGladys = await this.gladys.stateManager.get('deviceByExternalId', externalId); + if (deviceExistInGladys) { + await this.updateValues(deviceExistInGladys, device, externalId); + } else { + logger.info(`device ${externalId} - ${device.type} does not exist in Gladys`); + } + }, + { concurrency: 2 }, + ); + await this.saveStatus({ statusType: STATUS.CONNECTED, message: null }); +} + +/** + * @description Poll values of Netatmo devices. + * @example pollRefreshingValues(); + */ +function pollRefreshingValues() { + refreshNetatmoValues.bind(this)(); + this.pollRefreshValues = setInterval(refreshNetatmoValues.bind(this), 120 * 1000); +} + +module.exports = { + pollRefreshingValues, +}; diff --git a/server/services/netatmo/lib/netatmo.refreshingTokens.js b/server/services/netatmo/lib/netatmo.refreshingTokens.js new file mode 100644 index 0000000000..970395155a --- /dev/null +++ b/server/services/netatmo/lib/netatmo.refreshingTokens.js @@ -0,0 +1,66 @@ +const { default: axios } = require('axios'); +const querystring = require('querystring'); + +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); + +const { STATUS, API } = require('./utils/netatmo.constants'); + +/** + * @description Netatmo retrieve access and refresh token method. + * @returns {Promise} Netatmo access token success. + * @example + * await netatmo.refreshingTokens(); + */ +async function refreshingTokens() { + const { clientId, clientSecret } = this.configuration; + if (!clientId || !clientSecret) { + await this.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + throw new ServiceNotConfiguredError('Netatmo is not configured.'); + } + if (!this.accessToken || !this.refreshToken) { + logger.debug('Netatmo no access or no refresh token'); + await this.saveStatus({ statusType: STATUS.DISCONNECTED, message: null }); + throw new ServiceNotConfiguredError('Netatmo is not connected.'); + } + await this.saveStatus({ statusType: STATUS.PROCESSING_TOKEN, message: null }); + logger.debug('Loading Netatmo refreshing tokens...'); + const authentificationForm = { + grant_type: 'refresh_token', + client_id: clientId, + client_secret: clientSecret, + refresh_token: this.refreshToken, + }; + try { + const response = await axios({ + url: `${API.TOKEN}`, + method: 'post', + headers: { 'Content-Type': API.HEADER.CONTENT_TYPE, Host: API.HEADER.HOST }, + data: querystring.stringify(authentificationForm), + }); + const tokens = { + accessToken: response.data.access_token, + refreshToken: response.data.refresh_token, + expireIn: response.data.expire_in, + }; + await this.setTokens(tokens); + await this.saveStatus({ statusType: STATUS.CONNECTED, message: null }); + logger.debug('Netatmo new access tokens well loaded with status: ', this.status); + return { success: true }; + } catch (e) { + logger.error('Netatmo no successfull refresh token and disconnect'); + const tokens = { + accessToken: '', + refreshToken: '', + expireIn: '', + }; + await this.setTokens(tokens); + await this.saveStatus({ statusType: STATUS.ERROR.PROCESSING_TOKEN, message: 'refresh_token_fail' }); + logger.error('Error getting new accessToken to Netatmo - Details:', e.response ? e.response.data : e); + throw new ServiceNotConfiguredError(`NETATMO: Service is not connected with error ${e}`); + } +} + +module.exports = { + refreshingTokens, +}; diff --git a/server/services/netatmo/lib/netatmo.retrieveTokens.js b/server/services/netatmo/lib/netatmo.retrieveTokens.js new file mode 100644 index 0000000000..d9c6cb5908 --- /dev/null +++ b/server/services/netatmo/lib/netatmo.retrieveTokens.js @@ -0,0 +1,73 @@ +const { default: axios } = require('axios'); +const querystring = require('querystring'); + +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); + +const { STATUS, API } = require('./utils/netatmo.constants'); + +/** + * @description Netatmo retrieve access and refresh token method. + * @param {object} body - Netatmo code to retrieve access tokens. + * @returns {Promise} Netatmo access token. + * @example + * await netatmo.retrieveTokens( + * {codeOAuth, state, redirectUri}, + * ); + */ +async function retrieveTokens(body) { + logger.debug('Getting tokens to Netatmo API...'); + const { clientId, clientSecret, scopes } = this.configuration; + const { codeOAuth, state, redirectUri } = body; + if (!clientId || !clientSecret || !codeOAuth) { + await this.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + throw new ServiceNotConfiguredError('Netatmo is not configured.'); + } + if (state !== this.stateGetAccessToken) { + await this.saveStatus({ statusType: STATUS.DISCONNECTED, message: null }); + throw new ServiceNotConfiguredError( + 'Netatmo did not connect correctly. The return does not correspond to the initial request', + ); + } + await this.saveStatus({ statusType: STATUS.PROCESSING_TOKEN, message: null }); + const scopeValues = Object.values(scopes).join(' '); + const authentificationForm = { + grant_type: 'authorization_code', + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + scope: scopeValues, + code: body.codeOAuth, + }; + try { + const response = await axios({ + url: API.TOKEN, + method: 'post', + headers: { 'Content-Type': API.HEADER.CONTENT_TYPE, Host: API.HEADER.HOST }, + data: querystring.stringify(authentificationForm), + }); + const tokens = { + accessToken: response.data.access_token, + refreshToken: response.data.refresh_token, + expireIn: response.data.expire_in, + }; + await this.setTokens(tokens); + this.accessToken = tokens.accessToken; + await this.saveStatus({ statusType: STATUS.CONNECTED }); + logger.debug('Netatmo new access tokens well loaded'); + await this.pollRefreshingToken(); + await this.pollRefreshingValues(); + return { success: true }; + } catch (e) { + this.saveStatus({ + statusType: STATUS.ERROR.PROCESSING_TOKEN, + message: 'get_access_token_fail', + }); + logger.error('Error getting new accessToken to Netatmo - Details:', e.response ? e.response.data : e); + throw new ServiceNotConfiguredError(`NETATMO: Service is not connected with error ${e}`); + } +} + +module.exports = { + retrieveTokens, +}; diff --git a/server/services/netatmo/lib/netatmo.saveConfiguration.js b/server/services/netatmo/lib/netatmo.saveConfiguration.js new file mode 100644 index 0000000000..81710387bd --- /dev/null +++ b/server/services/netatmo/lib/netatmo.saveConfiguration.js @@ -0,0 +1,30 @@ +const logger = require('../../../utils/logger'); + +const { GLADYS_VARIABLES } = require('./utils/netatmo.constants'); + +/** + * @description Save Netatmo configuration. + * @param {object} configuration - Configuration to save. + * @returns {Promise} Netatmo well save configuration. + * @example + * await saveConfiguration({ endpoint: '...', accessKey: '...', secretKey: '...'}); + */ +async function saveConfiguration(configuration) { + logger.debug('Saving Netatmo configuration...'); + const { clientId, clientSecret } = configuration; + try { + await this.gladys.variable.setValue(GLADYS_VARIABLES.CLIENT_ID, clientId, this.serviceId); + await this.gladys.variable.setValue(GLADYS_VARIABLES.CLIENT_SECRET, clientSecret, this.serviceId); + this.configuration.clientId = clientId; + this.configuration.clientSecret = clientSecret; + logger.debug('Netatmo configuration well stored'); + return true; + } catch (e) { + logger.error('Netatmo configuration stored errored', e); + return false; + } +} + +module.exports = { + saveConfiguration, +}; diff --git a/server/services/netatmo/lib/netatmo.saveStatus.js b/server/services/netatmo/lib/netatmo.saveStatus.js new file mode 100644 index 0000000000..43f7453236 --- /dev/null +++ b/server/services/netatmo/lib/netatmo.saveStatus.js @@ -0,0 +1,113 @@ +const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../utils/constants'); +const logger = require('../../../utils/logger'); +const { STATUS } = require('./utils/netatmo.constants'); + +/** + * @description Post Netatmo status. + * @param {object} status - Configuration to save. + * @returns {object} Current Netatmo network status. + * @example + * status({statusType: 'connecting', message: 'invalid_client'}); + */ +function saveStatus(status) { + logger.debug('Changing status Netatmo ...'); + try { + switch (status.statusType) { + case STATUS.ERROR.CONNECTING: + this.status = STATUS.DISCONNECTED; + this.connected = false; + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.CONNECTING, + payload: { statusType: STATUS.CONNECTING, status: status.message }, + }); + break; + case STATUS.ERROR.PROCESSING_TOKEN: + this.status = STATUS.DISCONNECTED; + this.connected = false; + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.PROCESSING_TOKEN, + payload: { statusType: STATUS.PROCESSING_TOKEN, status: status.message }, + }); + break; + case STATUS.ERROR.CONNECTED: + this.configured = true; + this.status = STATUS.DISCONNECTED; + this.connected = false; + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.CONNECTED, + payload: { statusType: STATUS.CONNECTED, status: status.message }, + }); + break; + case STATUS.ERROR.SET_DEVICES_VALUES: + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.CONNECTED, + payload: { statusType: STATUS.CONNECTED, status: status.message }, + }); + break; + case STATUS.ERROR.GET_DEVICES_VALUES: + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.CONNECTED, + payload: { statusType: STATUS.CONNECTED, status: status.message }, + }); + break; + + case STATUS.NOT_INITIALIZED: + this.configured = false; + this.status = STATUS.NOT_INITIALIZED; + this.connected = false; + clearInterval(this.pollRefreshToken); + clearInterval(this.pollRefreshValues); + break; + case STATUS.CONNECTING: + this.configured = true; + this.status = STATUS.CONNECTING; + this.connected = false; + break; + case STATUS.PROCESSING_TOKEN: + this.configured = true; + this.status = STATUS.PROCESSING_TOKEN; + this.connected = false; + break; + case STATUS.CONNECTED: + this.configured = true; + this.status = STATUS.CONNECTED; + this.connected = true; + break; + case STATUS.DISCONNECTING: + this.configured = true; + this.status = STATUS.DISCONNECTING; + break; + case STATUS.DISCONNECTED: + this.configured = true; + this.status = STATUS.DISCONNECTED; + this.connected = false; + clearInterval(this.pollRefreshToken); + clearInterval(this.pollRefreshValues); + break; + case STATUS.DISCOVERING_DEVICES: + this.configured = true; + this.status = STATUS.DISCOVERING_DEVICES; + this.connected = true; + break; + case STATUS.GET_DEVICES_VALUES: + this.configured = true; + this.status = STATUS.GET_DEVICES_VALUES; + this.connected = true; + break; + + default: + break; + } + logger.debug('Status Netatmo well changed'); + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.STATUS, + payload: { status: this.status }, + }); + return true; + } catch (e) { + return false; + } +} +module.exports = { + saveStatus, +}; diff --git a/server/services/netatmo/lib/netatmo.setTokens.js b/server/services/netatmo/lib/netatmo.setTokens.js new file mode 100644 index 0000000000..8324c105c2 --- /dev/null +++ b/server/services/netatmo/lib/netatmo.setTokens.js @@ -0,0 +1,32 @@ +const logger = require('../../../utils/logger'); +const { GLADYS_VARIABLES } = require('./utils/netatmo.constants'); + +/** + * @description Netatmo save token method. + * @param {object} tokens - Netatmo tokens. + * @returns {Promise} Netatmo well set Tokens. + * @example + * await netatmo.setTokens({ access_token: '...', refresh_token:'...', expire_time: ...}); + */ +async function setTokens(tokens) { + logger.debug('Storing Netatmo tokens...'); + const { serviceId } = this; + const { accessToken, refreshToken, expireIn } = tokens; + try { + await this.gladys.variable.setValue(GLADYS_VARIABLES.ACCESS_TOKEN, accessToken, serviceId); + await this.gladys.variable.setValue(GLADYS_VARIABLES.REFRESH_TOKEN, refreshToken, serviceId); + await this.gladys.variable.setValue(GLADYS_VARIABLES.EXPIRE_IN_TOKEN, expireIn, serviceId); + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.expireInToken = expireIn; + logger.debug('Netatmo tokens well stored'); + return true; + } catch (e) { + logger.error('Netatmo tokens stored errored', e); + return false; + } +} + +module.exports = { + setTokens, +}; diff --git a/server/services/netatmo/lib/netatmo.setValue.js b/server/services/netatmo/lib/netatmo.setValue.js new file mode 100644 index 0000000000..e844bd304c --- /dev/null +++ b/server/services/netatmo/lib/netatmo.setValue.js @@ -0,0 +1,76 @@ +const { default: axios } = require('axios'); +const logger = require('../../../utils/logger'); +const { API, STATUS, PARAMS } = require('./utils/netatmo.constants'); +const { BadParameters } = require('../../../utils/coreErrors'); +const { writeValues } = require('./device/netatmo.deviceMapping'); + +/** + * @description Send the new device value over device protocol. + * @param {object} device - Updated Gladys device. + * @param {object} deviceFeature - Updated Gladys device feature. + * @param {string|number} value - The new device feature value. + * @example + * setValue(device, deviceFeature, 0); + */ +async function setValue(device, deviceFeature, value) { + const externalId = deviceFeature.external_id; + const homeId = device.params.find((oneParam) => oneParam.name === PARAMS.HOME_ID); + const roomId = device.params.find((oneParam) => oneParam.name === PARAMS.ROOM_ID); + if (!homeId || !roomId) { + throw new BadParameters( + `Netatmo device external_id: "${externalId}" should contains parameters "HOME_ID" and "ROOM_ID"`, + ); + } + const [prefix, ...topic] = externalId.split(':'); + if (prefix !== 'netatmo') { + throw new BadParameters(`Netatmo device external_id is invalid: "${externalId}" should starts with "netatmo:"`); + } + if (!topic || topic.length === 0) { + throw new BadParameters(`Netatmo device external_id is invalid: "${externalId}" have no id and category indicator`); + } + const featureName = topic[topic.length - 1]; + + const transformedValue = writeValues[deviceFeature.category][deviceFeature.type](value); + logger.debug(`Change value for device ${device.name} / ${featureName} to value ${transformedValue}...`); + + const paramsForm = { + home_id: homeId.value, // mandatory + room_id: roomId.value, // mandatory + mode: 'manual', // mandatory + temp: transformedValue, + }; + try { + await axios({ + url: API.SET_ROOM_THERMPOINT, + method: 'post', + headers: { accept: API.HEADER.ACCEPT, Authorization: `Bearer ${this.accessToken}` }, + data: paramsForm, + }); + logger.debug(`Value has been changed on the device ${device.name} / ${featureName}: ${transformedValue}`); + } catch (e) { + logger.error( + 'setValue error with status code: ', + e.code, + ' - ', + e.response.status, + 'and with status message: ', + e.response.statusText, + ); + logger.error('error details: ', e.response.data.error.code, ' - ', e.response.data.error.message); + if (e.response.status === 403 && e.response.data.error.code === 13) { + await this.saveStatus({ + statusType: STATUS.ERROR.SET_DEVICES_VALUES, + message: 'set_devices_value_fail_scope_rights', + }); + } else { + await this.saveStatus({ + statusType: STATUS.ERROR.SET_DEVICES_VALUES, + message: 'set_devices_value_error_unknown', + }); + } + } +} + +module.exports = { + setValue, +}; diff --git a/server/services/netatmo/lib/netatmo.updateValues.js b/server/services/netatmo/lib/netatmo.updateValues.js new file mode 100644 index 0000000000..9bc7be7e3c --- /dev/null +++ b/server/services/netatmo/lib/netatmo.updateValues.js @@ -0,0 +1,35 @@ +const { BadParameters } = require('../../../utils/coreErrors'); +const { SUPPORTED_MODULE_TYPE } = require('./utils/netatmo.constants'); + +/** + * @description Save values of an Netatmo device. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} deviceNetatmo - Device object coming from the Netatmo API. + * @param {string} externalId - Device identifier in gladys. + * @example updateValues(deviceGladys, deviceNetatmo, externalId); + */ +async function updateValues(deviceGladys, deviceNetatmo, externalId) { + const [prefix, topic] = externalId.split(':'); + if (prefix !== 'netatmo') { + throw new BadParameters(`Netatmo device external_id is invalid: "${externalId}" should starts with "netatmo:"`); + } + if (!topic || topic.length === 0) { + throw new BadParameters(`Netatmo device external_id is invalid: "${externalId}" have no id and category indicator`); + } + switch (deviceNetatmo.type) { + case SUPPORTED_MODULE_TYPE.PLUG: { + await this.updateNAPlug(deviceGladys, deviceNetatmo, externalId); + break; + } + case SUPPORTED_MODULE_TYPE.THERMOSTAT: { + await this.updateNATherm1(deviceGladys, deviceNetatmo, externalId); + break; + } + default: + break; + } +} + +module.exports = { + updateValues, +}; diff --git a/server/services/netatmo/lib/utils/netatmo.buildScopesConfig.js b/server/services/netatmo/lib/utils/netatmo.buildScopesConfig.js new file mode 100644 index 0000000000..5fe22d183f --- /dev/null +++ b/server/services/netatmo/lib/utils/netatmo.buildScopesConfig.js @@ -0,0 +1,26 @@ +/** + * @description Poll refreshing Token values of an Netatmo device. + * @param {object} scopes - Scopes format. + * @returns {object} Object scopes string - Returns all scopes as a string object separating scopes by API. + * @example + * buildScopesConfig({ + * ENERGY: { + * read: 'read_thermostat', + * write: 'write_thermostat', + * }}); + */ +function buildScopesConfig(scopes) { + const scopesConfig = {}; + Object.keys(scopes).forEach((key) => { + const words = key.toLowerCase().split('_'); + const camelCaseKey = words + .map((word, index) => (index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1))) + .join(''); + const scopeKey = `scope${camelCaseKey.charAt(0).toUpperCase() + camelCaseKey.slice(1)}`; + scopesConfig[scopeKey] = Object.values(scopes[key]).join(' '); + }); + + return scopesConfig; +} + +module.exports = buildScopesConfig; diff --git a/server/services/netatmo/lib/utils/netatmo.constants.js b/server/services/netatmo/lib/utils/netatmo.constants.js new file mode 100644 index 0000000000..bad1368143 --- /dev/null +++ b/server/services/netatmo/lib/utils/netatmo.constants.js @@ -0,0 +1,95 @@ +const GLADYS_VARIABLES = { + CLIENT_ID: 'NETATMO_CLIENT_ID', + CLIENT_SECRET: 'NETATMO_CLIENT_SECRET', + + ACCESS_TOKEN: 'NETATMO_ACCESS_TOKEN', + REFRESH_TOKEN: 'NETATMO_REFRESH_TOKEN', + EXPIRE_IN_TOKEN: 'NETATMO_EXPIRE_IN_TOKEN', +}; + +const SCOPES = { + ENERGY: { + read: 'read_thermostat', + write: 'write_thermostat', + }, + HOME_SECURITY: { + read_camera: 'read_camera', + read_presence: 'read_presence', + read_carbonmonoxidedetector: 'read_carbonmonoxidedetector', + read_smokedetector: 'read_smokedetector', + }, + WEATHER: { + read_station: 'read_station', + }, + AIRCARE: { + read_homecoach: 'read_homecoach', + }, +}; + +const STATUS = { + NOT_INITIALIZED: 'not_initialized', + CONNECTING: 'connecting', + DISCONNECTING: 'disconnecting', + PROCESSING_TOKEN: 'processing token', + CONNECTED: 'connected', + DISCONNECTED: 'disconnected', + ERROR: { + CONNECTING: 'error connecting', + PROCESSING_TOKEN: 'error processing token', + DISCONNECTING: 'error disconnecting', + CONNECTED: 'error connected', + SET_DEVICES_VALUES: 'error set devices values', + GET_DEVICES_VALUES: 'error get devices values', + }, + GET_DEVICES_VALUES: 'get devices values', + DISCOVERING_DEVICES: 'discovering', +}; + +const GITHUB_BASE_URL = 'https://github.com/GladysAssistant/Gladys/issues/new'; +const BASE_API = 'https://api.netatmo.com'; +const API = { + HEADER: { + ACCEPT: 'application/json', + HOST: 'api.netatmo.com', + CONTENT_TYPE: 'application/x-www-form-urlencoded;charset=UTF-8', + }, + OAUTH2: `${BASE_API}/oauth2/authorize`, + TOKEN: `${BASE_API}/oauth2/token`, + GET_THERMOSTATS: `${BASE_API}/api/getthermostatsdata`, + POST_THERMPOINT: `${BASE_API}/api/setroomthermpoint`, + HOMESDATA: `${BASE_API}/api/homesdata`, + HOMESTATUS: `${BASE_API}/api/homestatus`, + GET_ROOM_MEASURE: `${BASE_API}/api/getroommeasure`, + SET_ROOM_THERMPOINT: `${BASE_API}/api/setroomthermpoint`, + SET_THERM_MODE: `${BASE_API}/api/setthermmode`, + GET_MEASURE: `${BASE_API}/api/getmeasure`, +}; + +const SUPPORTED_CATEGORY_TYPE = { + ENERGY: 'Energy', +}; + +const SUPPORTED_MODULE_TYPE = { + THERMOSTAT: 'NATherm1', + PLUG: 'NAPlug', +}; + +const PARAMS = { + HOME_ID: 'home_id', + ROOM_ID: 'room_id', + ROOM_NAME: 'room_name', + PLUG_ID: 'plug_id', + PLUG_NAME: 'plug_name', + MODULES_BRIDGE_ID: 'modules_bridge_id', +}; + +module.exports = { + GLADYS_VARIABLES, + SCOPES, + STATUS, + GITHUB_BASE_URL, + API, + SUPPORTED_CATEGORY_TYPE, + SUPPORTED_MODULE_TYPE, + PARAMS, +}; diff --git a/server/services/netatmo/package-lock.json b/server/services/netatmo/package-lock.json new file mode 100644 index 0000000000..a399859eea --- /dev/null +++ b/server/services/netatmo/package-lock.json @@ -0,0 +1,121 @@ +{ + "name": "gladys-netatmo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gladys-netatmo", + "version": "1.0.0", + "cpu": [ + "x64", + "arm", + "arm64" + ], + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "axios": "^1.6.2", + "bluebird": "^3.7.2" + } + }, + "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.6.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", + "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", + "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + } + } +} diff --git a/server/services/netatmo/package.json b/server/services/netatmo/package.json new file mode 100644 index 0000000000..d69262f580 --- /dev/null +++ b/server/services/netatmo/package.json @@ -0,0 +1,19 @@ +{ + "name": "gladys-netatmo", + "version": "1.0.0", + "main": "index.js", + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm", + "arm64" + ], + "dependencies": { + "axios": "^1.6.2", + "bluebird": "^3.7.2" + } +} diff --git a/server/test/services/netatmo/controllers/netatmo.controller.test.js b/server/test/services/netatmo/controllers/netatmo.controller.test.js new file mode 100644 index 0000000000..b848bab88f --- /dev/null +++ b/server/test/services/netatmo/controllers/netatmo.controller.test.js @@ -0,0 +1,132 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const NetatmoController = require('../../../../services/netatmo/api/netatmo.controller'); +const { NetatmoHandlerMock } = require('../netatmo.mock.test'); + +const netatmoController = NetatmoController(NetatmoHandlerMock); + +describe('Netatmo Controller', () => { + let req; + let res; + + beforeEach(() => { + sinon.reset(); + + req = { + body: {}, + params: {}, + query: {}, + }; + + res = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + }; + }); + + describe('getConfiguration', () => { + it('should get the netatmo configuration', async () => { + const configuration = { clientId: 'test', clientSecret: 'test', redirectUri: 'test' }; + NetatmoHandlerMock.getConfiguration.resolves(configuration); + + await netatmoController['get /api/v1/service/netatmo/config'].controller(req, res); + expect(res.json.calledWith(configuration)).to.equal(true); + }); + }); + + describe('getStatus', () => { + it('should get the netatmo status', async () => { + const status = { connected: true }; + NetatmoHandlerMock.getStatus.resolves(status); + + await netatmoController['get /api/v1/service/netatmo/status'].controller(req, res); + expect(res.json.calledWith(status)).to.equal(true); + }); + }); + + describe('saveConfiguration', () => { + it('should save the netatmo configuration', async () => { + const configuration = { clientId: 'test', clientSecret: 'test', redirectUri: 'test' }; + req.body = configuration; + NetatmoHandlerMock.saveConfiguration.resolves(true); + + await netatmoController['post /api/v1/service/netatmo/configuration'].controller(req, res); + expect(res.json.calledWith({ success: true })).to.equal(true); + }); + }); + + describe('saveStatus', () => { + it('should save the netatmo status', async () => { + const status = { connected: true }; + req.body = status; + NetatmoHandlerMock.saveStatus.resolves(true); + + await netatmoController['post /api/v1/service/netatmo/status'].controller(req, res); + expect(res.json.calledWith({ success: true })).to.equal(true); + }); + }); + + describe('connect', () => { + it('should connect netatmo', async () => { + NetatmoHandlerMock.getConfiguration.resolves(true); + NetatmoHandlerMock.connect.resolves(true); + + await netatmoController['post /api/v1/service/netatmo/connect'].controller(req, res); + expect(res.json.calledWith(true)).to.equal(true); + }); + }); + + describe('retrieveTokens', () => { + it('should retrieve netatmo tokens', async () => { + req.body = { code: 'test-code' }; + NetatmoHandlerMock.retrieveTokens.resolves({ accessToken: 'test-token', refreshToken: 'test-refresh-token' }); + + await netatmoController['post /api/v1/service/netatmo/token'].controller(req, res); + expect( + res.json.calledWith({ + accessToken: 'test-token', + refreshToken: 'test-refresh-token', + }), + ).to.equal(true); + }); + }); + + describe('disconnect', () => { + it('should disconnect netatmo', async () => { + NetatmoHandlerMock.disconnect.resolves(); + + await netatmoController['post /api/v1/service/netatmo/disconnect'].controller(req, res); + expect(res.json.calledWith({ success: true })).to.equal(true); + }); + }); + + describe('discover', () => { + it('should discover netatmo devices', async () => { + const devices = [{ id: 'device1' }, { id: 'device2' }]; + NetatmoHandlerMock.discoverDevices.resolves(devices); + + await netatmoController['get /api/v1/service/netatmo/discover'].controller(req, res); + expect(res.json.calledWith(devices)).to.equal(true); + }); + it('should return already discovered devices that are not in Gladys', async () => { + const discoveredDevices = [ + { external_id: 'netatmo:70:ee:50:xx:xx:e0', notInGladys: true }, + { external_id: 'netatmo:70:ee:50:xx:xx:e1', notInGladys: false }, + ]; + NetatmoHandlerMock.discoveredDevices = discoveredDevices; + NetatmoHandlerMock.gladys = { + stateManager: { + get: sinon.stub().callsFake((type, externalId) => { + const device = discoveredDevices.find((d) => d.external_id === externalId); + return device && !device.notInGladys ? {} : null; + }), + }, + }; + await netatmoController['get /api/v1/service/netatmo/discover'].controller(req, res); + + const expectedDevices = discoveredDevices.filter((d) => d.notInGladys); + expect(res.json.calledWith(expectedDevices)).to.equal(true); + }); + }); +}); diff --git a/server/test/services/netatmo/index.test.js b/server/test/services/netatmo/index.test.js new file mode 100644 index 0000000000..19964cf0eb --- /dev/null +++ b/server/test/services/netatmo/index.test.js @@ -0,0 +1,58 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); + +const { NetatmoHandlerMock } = require('./netatmo.mock.test'); +const { STATUS } = require('../../../services/netatmo/lib/utils/netatmo.constants'); + +describe('Netatmo Service', () => { + let NetatmoService; + let netatmoService; + let gladys; + let serviceId; + + beforeEach(() => { + gladys = { service: { getService: sinon.stub() } }; + serviceId = 'some-service-id'; + + NetatmoService = proxyquire('../../../services/netatmo/index', { + './lib': function mockFunction() { + return NetatmoHandlerMock; + }, + }); + + netatmoService = NetatmoService(gladys, serviceId); + }); + + afterEach(() => { + sinon.reset(); + }); + + describe('start', () => { + it('should start the service correctly', async () => { + await netatmoService.start(); + expect(NetatmoHandlerMock.init.calledOnce).to.equal(true); + }); + }); + + describe('stop', () => { + it('should stop the service correctly', async () => { + await netatmoService.stop(); + expect(NetatmoHandlerMock.disconnect.calledOnce).to.equal(true); + }); + }); + + describe('isUsed', () => { + it('should return true when Netatmo is connected', async () => { + NetatmoHandlerMock.status = STATUS.CONNECTED; + const result = await netatmoService.isUsed(); + expect(result).to.equal(true); + }); + + it('should return false when Netatmo is not connected', async () => { + NetatmoHandlerMock.status = STATUS.NOT_INITIALIZED; + const result = await netatmoService.isUsed(); + expect(result).to.equal(false); + }); + }); +}); diff --git a/server/test/services/netatmo/lib/device/netatmo.convertDevice.test.js b/server/test/services/netatmo/lib/device/netatmo.convertDevice.test.js new file mode 100644 index 0000000000..1f5005b658 --- /dev/null +++ b/server/test/services/netatmo/lib/device/netatmo.convertDevice.test.js @@ -0,0 +1,115 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { convertDevice } = require('../../../../../services/netatmo/lib/device/netatmo.convertDevice'); +const { SUPPORTED_MODULE_TYPE } = require('../../../../../services/netatmo/lib/utils/netatmo.constants'); +const devicesMock = require('../../netatmo.loadDevices.mock.test.json'); +const devicesGladysMock = require('../../netatmo.convertDevices.mock.test.json'); +const NetatmoHandler = require('../../../../../services/netatmo/lib/index'); + +const gladys = {}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo Convert Device', () => { + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should correctly convert a Netatmo Plug device', () => { + const deviceGladysMock = devicesGladysMock.filter((device) => device.model === 'NAPlug')[0]; + const deviceMock = devicesMock.filter((device) => device.type === 'NAPlug')[0]; + + const gladysDevice = convertDevice.bind(netatmoHandler)(deviceMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.PLUG); + + const featureMock = gladysDevice.features.filter((feature) => feature.name.includes('connected boiler'))[0]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceMock.id}:plug_connected_boiler`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'modules_bridge_id')[0]; + expect(paramMock).to.have.property('value', JSON.stringify(deviceMock.modules_bridged)); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Thermostat device', () => { + const deviceGladysMock = devicesGladysMock.filter((device) => device.model === 'NATherm1')[0]; + const deviceMock = devicesMock.filter((device) => device.type === 'NATherm1')[0]; + + const gladysDevice = convertDevice.bind(netatmoHandler)(deviceMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.THERMOSTAT); + + const featureMock = gladysDevice.features.filter((feature) => feature.type === 'target-temperature')[0]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceMock.id}:therm_setpoint_temperature`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceMock.plug.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo device not supported', () => { + const deviceGladysMock = devicesGladysMock.filter((device) => device.not_handled)[0]; + const deviceMock = devicesMock.filter((device) => device.type === 'NOC')[0]; + + const gladysDevice = convertDevice.bind(netatmoHandler)(deviceMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal([]); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceMock.id}`); + expect(gladysDevice).to.have.property('model', 'NOC'); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'room_name')[0]; + expect(paramMock).to.have.property('value', deviceMock.room.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo device without room and without modules_bridged', () => { + const deviceGladysMock = devicesGladysMock.filter((device) => device.model === 'NAPlug')[1]; + const deviceMock = devicesMock.filter((device) => device.type === 'NAPlug')[1]; + + const gladysDevice = convertDevice.bind(netatmoHandler)(deviceMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.PLUG); + + const featureMock = gladysDevice.features.filter((feature) => feature.name.includes('connected boiler'))[0]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceMock.id}:plug_connected_boiler`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'modules_bridge_id')[0]; + expect(paramMock).deep.equal({ name: 'modules_bridge_id', value: '[]' }); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); +}); diff --git a/server/test/services/netatmo/lib/device/netatmo.deviceMapping.test.js b/server/test/services/netatmo/lib/device/netatmo.deviceMapping.test.js new file mode 100644 index 0000000000..ec23f2388f --- /dev/null +++ b/server/test/services/netatmo/lib/device/netatmo.deviceMapping.test.js @@ -0,0 +1,75 @@ +const { expect } = require('chai'); +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../../utils/constants'); +const { writeValues, readValues } = require('../../../../../services/netatmo/lib/device/netatmo.deviceMapping'); + +describe('Netatmo device mapping', () => { + describe('writeValues', () => { + it('should correctly transform THERMOSTAT.TARGET_TEMPERATURE value from Gladys to Netatmo', () => { + const thermostatValue = 21; + const mappingFunction = + writeValues[DEVICE_FEATURE_CATEGORIES.THERMOSTAT][DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE]; + + expect(mappingFunction(thermostatValue)).to.equal(21); + }); + }); + + describe('readValues', () => { + it('should correctly transform THERMOSTAT.TARGET_TEMPERATURE value from Netatmo to Gladys', () => { + const thermostatValue = 21.5; + const mappingFunction = + readValues[DEVICE_FEATURE_CATEGORIES.THERMOSTAT][DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE]; + + expect(mappingFunction(thermostatValue)).to.equal(21.5); + }); + + it('should correctly transform SWITCH.BINARY value from Netatmo to Gladys', () => { + const binarySwitchValueTrue = true; + const binarySwitchValueFalse = false; + const numberSwitchValueTrue = 1; + const numberSwitchValueFalse = 0; + const mappingFunction = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.BINARY]; + + expect(mappingFunction(binarySwitchValueTrue)).to.eq(1); + expect(mappingFunction(binarySwitchValueFalse)).to.eq(0); + expect(mappingFunction(numberSwitchValueTrue)).to.eq(1); + expect(mappingFunction(numberSwitchValueFalse)).to.eq(0); + }); + + it('should correctly transform BATTERY.INTEGER value from Netatmo to Gladys', () => { + const valueFromDevice = 60.5; + const mappingFunction = readValues[DEVICE_FEATURE_CATEGORIES.BATTERY][DEVICE_FEATURE_TYPES.BATTERY.INTEGER]; + + expect(mappingFunction(valueFromDevice)).to.equal(60); + }); + + it('should correctly transform TEMPERATURE_SENSOR.DECIMAL value from Netatmo to Gladys', () => { + const valueFromDevice = 20.5; + const mappingFunction = + readValues[DEVICE_FEATURE_CATEGORIES.TEMPERATURE_SENSOR][DEVICE_FEATURE_TYPES.SENSOR.DECIMAL]; + + expect(mappingFunction(valueFromDevice)).to.equal(20.5); + }); + + it('should correctly transform SIGNAL.QUALITY value from Netatmo to Gladys', () => { + const valueFromDevice = 76; + const valueFromDeviceFloat = 76.5; + const mappingFunction = readValues[DEVICE_FEATURE_CATEGORIES.SIGNAL][DEVICE_FEATURE_TYPES.SIGNAL.QUALITY]; + + expect(mappingFunction(valueFromDevice)).to.equal(76); + expect(mappingFunction(valueFromDeviceFloat)).to.equal(76); + }); + + it('should correctly transform OPENING_SENSOR.BINARY value from Netatmo to Gladys', () => { + const binarySwitchValueTrue = true; + const binarySwitchValueFalse = false; + const numberSwitchValueTrue = 1; + const numberSwitchValueFalse = 0; + const mappingFunction = readValues[DEVICE_FEATURE_CATEGORIES.OPENING_SENSOR][DEVICE_FEATURE_TYPES.SENSOR.BINARY]; + + expect(mappingFunction(binarySwitchValueTrue)).to.eq(1); + expect(mappingFunction(binarySwitchValueFalse)).to.eq(0); + expect(mappingFunction(numberSwitchValueTrue)).to.eq(1); + expect(mappingFunction(numberSwitchValueFalse)).to.eq(0); + }); + }); +}); diff --git a/server/test/services/netatmo/lib/device/netatmo.updateNAPlug.test.js b/server/test/services/netatmo/lib/device/netatmo.updateNAPlug.test.js new file mode 100644 index 0000000000..499ffb0654 --- /dev/null +++ b/server/test/services/netatmo/lib/device/netatmo.updateNAPlug.test.js @@ -0,0 +1,114 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = require('../../netatmo.convertDevices.mock.test.json'); +const devicesNetatmo = require('../../netatmo.loadDevices.mock.test.json'); +const { EVENTS } = require('../../../../../utils/constants'); +const NetatmoHandler = require('../../../../../services/netatmo/lib/index'); +const logger = require('../../../../../utils/logger'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; + +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo update NAPlug features', () => { + const deviceGladys = devicesGladys[0]; + const deviceNetatmoPlug = JSON.parse(JSON.stringify(devicesNetatmo[0])); + const externalIdNAPlug = `netatmo:${deviceNetatmoPlug.id}`; + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should update device values correctly for a plug device connected on boiler', async () => { + await netatmoHandler.updateNAPlug(deviceGladys, deviceNetatmoPlug, externalIdNAPlug); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladys.features.length); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:rf_strength', + state: 70, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:rf_strength', + state: 70, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:wifi_strength', + state: 45, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:plug_connected_boiler', + state: 0, + }), + ).to.equal(true); + }); + + it('should update device values correctly for a plug device no connected boiler', async () => { + deviceNetatmoPlug.plug_connected_boiler = undefined; + + await netatmoHandler.updateNAPlug(deviceGladys, deviceNetatmoPlug, externalIdNAPlug); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladys.features.length); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:rf_strength', + state: 70, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:rf_strength', + state: 70, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:wifi_strength', + state: 45, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:plug_connected_boiler', + state: 0, + }), + ).to.equal(true); + }); + it('should handle errors correctly', async () => { + deviceNetatmoPlug.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNAPlug(deviceGladys, deviceNetatmoPlug, externalIdNAPlug); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/netatmo/lib/device/netatmo.updateNATherm1.test.js b/server/test/services/netatmo/lib/device/netatmo.updateNATherm1.test.js new file mode 100644 index 0000000000..d704c48848 --- /dev/null +++ b/server/test/services/netatmo/lib/device/netatmo.updateNATherm1.test.js @@ -0,0 +1,108 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = require('../../netatmo.convertDevices.mock.test.json'); +const devicesNetatmo = require('../../netatmo.loadDevices.mock.test.json'); +const { EVENTS } = require('../../../../../utils/constants'); +const NetatmoHandler = require('../../../../../services/netatmo/lib/index'); +const logger = require('../../../../../utils/logger'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; + +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo update NATherm1 features', () => { + const deviceGladys = devicesGladys[1]; + const deviceNetatmoNATherm1 = JSON.parse(JSON.stringify(devicesNetatmo[1])); + const externalIdNATherm1 = `netatmo:${devicesNetatmo[1].id}`; + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases', async () => { + await netatmoHandler.updateNATherm1(deviceGladys, deviceNetatmoNATherm1, externalIdNATherm1); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(7); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladys.external_id}:battery_percent`, + state: 60, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:04:00:00:xx:xx:xx:battery_percent', + state: 60, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:04:00:00:xx:xx:xx:temperature', + state: 19.6, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:04:00:00:xx:xx:xx:therm_measured_temperature', + state: 19.4, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(3).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:04:00:00:xx:xx:xx:therm_setpoint_temperature', + state: 19.5, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(4).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:04:00:00:xx:xx:xx:open_window', + state: 0, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(5).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:04:00:00:xx:xx:xx:rf_strength', + state: 60, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(6).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:04:00:00:xx:xx:xx:boiler_status', + state: 1, + }), + ).to.equal(true); + }); + it('should handle errors correctly', async () => { + deviceNetatmoNATherm1.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNATherm1(deviceGladys, deviceNetatmoNATherm1, externalIdNATherm1); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/netatmo/lib/index.test.js b/server/test/services/netatmo/lib/index.test.js new file mode 100644 index 0000000000..120220fb45 --- /dev/null +++ b/server/test/services/netatmo/lib/index.test.js @@ -0,0 +1,23 @@ +const { expect } = require('chai'); +const NetatmoHandler = require('../../../../services/netatmo/lib'); + +const gladys = {}; +const serviceId = '123'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('NetatmoHandler Constructor', () => { + it('should properly initialize properties', () => { + expect(netatmoHandler.gladys).to.equal(gladys); + expect(netatmoHandler.serviceId).to.equal(serviceId); + expect(netatmoHandler.configuration).to.deep.equal({ + clientId: null, + clientSecret: null, + scopes: { + scopeAircare: 'read_homecoach', + scopeEnergy: 'read_thermostat write_thermostat', + scopeHomeSecurity: 'read_camera read_presence read_carbonmonoxidedetector read_smokedetector', + scopeWeather: 'read_station', + }, + }); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.connect.test.js b/server/test/services/netatmo/lib/netatmo.connect.test.js new file mode 100644 index 0000000000..9f54d1297c --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.connect.test.js @@ -0,0 +1,49 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const nock = require('nock'); + +const { fake } = sinon; + +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo connect', () => { + beforeEach(() => { + sinon.reset(); + nock.cleanAll(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + nock.cleanAll(); + }); + + it('should throw an error if netatmo is not configured', async () => { + try { + await netatmoHandler.connect(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e.message).to.equal('Netatmo is not configured.'); + } + }); + + it('should return auth url and state if netatmo is configured', async () => { + netatmoHandler.configuration.clientId = 'test-client-id'; + netatmoHandler.configuration.clientSecret = 'test-client-secret'; + netatmoHandler.configuration.scopes = { scopeEnergy: 'scope' }; + + const result = await netatmoHandler.connect(); + expect(result).to.have.property('authUrl'); + expect(result).to.have.property('state'); + expect(netatmoHandler.configured).to.equal(true); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.disconnect.test.js b/server/test/services/netatmo/lib/netatmo.disconnect.test.js new file mode 100644 index 0000000000..47bb468e1b --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.disconnect.test.js @@ -0,0 +1,59 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { assert, fake } = sinon; + +const { EVENTS } = require('../../../../utils/constants'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo Disconnect', () => { + let clock; + + beforeEach(() => { + sinon.reset(); + clock = sinon.useFakeTimers(); + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + clock.restore(); + sinon.reset(); + }); + + it('should properly disconnect from Netatmo', () => { + sinon.spy(clock, 'clearInterval'); + const intervalPollRefreshTokenSpy = sinon.spy(); + const intervalPollRefreshValuesSpy = sinon.spy(); + netatmoHandler.pollRefreshToken = setInterval(intervalPollRefreshTokenSpy, 3600 * 1000); + netatmoHandler.pollRefreshValues = setInterval(intervalPollRefreshValuesSpy, 120 * 1000); + + netatmoHandler.disconnect(); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnecting' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + + clock.tick(3600 * 1000 * 2); + assert.calledTwice(clock.clearInterval); + expect(intervalPollRefreshTokenSpy.notCalled).to.equal(true); + expect(intervalPollRefreshValuesSpy.notCalled).to.equal(true); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.discoverDevices.test.js b/server/test/services/netatmo/lib/netatmo.discoverDevices.test.js new file mode 100644 index 0000000000..8d8451094e --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.discoverDevices.test.js @@ -0,0 +1,103 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const nock = require('nock'); + +const { fake } = sinon; + +const devicesMock = require('../netatmo.loadDevices.mock.test.json'); +const discoverDevicesMock = require('../netatmo.discoverDevices.mock.test.json'); +const { EVENTS } = require('../../../../utils/constants'); +const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + stateManager: { + get: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo Discover devices', () => { + beforeEach(() => { + sinon.reset(); + nock.cleanAll(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + nock.cleanAll(); + }); + + it('should discover devices successfully', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.gladys.stateManager.get = sinon.stub().returns(null); + netatmoHandler.loadDevices = sinon.stub().returns(devicesMock); + + const discoveredDevices = await netatmoHandler.discoverDevices(); + + expect(discoveredDevices.length).to.equal(devicesMock.length); + expect(discoveredDevices[0].external_id).to.equal(`netatmo:${devicesMock[0].id}`); + expect(discoveredDevices[1].external_id).to.equal(`netatmo:${devicesMock[1].id}`); + expect(discoverDevicesMock[0]).to.deep.equal(discoveredDevices[0]); + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'discovering' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }), + ).to.equal(true); + }); + + it('should throw an error if not connected', async () => { + try { + await netatmoHandler.discoverDevices(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.equal('Unable to discover Netatmo devices until service is not well configured'); + expect(netatmoHandler.status).to.equal('not_initialized'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'not_initialized' }, + }), + ).to.equal(true); + } + }); + + it('should handle an error during device loading', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.loadDevices = sinon.stub().rejects(new Error('Failed to load')); + + const discoveredDevices = await netatmoHandler.discoverDevices(); + + expect(discoveredDevices).to.deep.equal([]); + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + }); + + it('should handle no devices found', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.loadDevices = sinon.stub().resolves([]); + + const discoveredDevices = await netatmoHandler.discoverDevices(); + + expect(discoveredDevices).to.deep.equal([]); + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.getAccessToken.test.js b/server/test/services/netatmo/lib/netatmo.getAccessToken.test.js new file mode 100644 index 0000000000..b35b7af667 --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.getAccessToken.test.js @@ -0,0 +1,71 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const { EVENTS } = require('../../../../utils/constants'); +const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + getValue: fake.returns('valid_access_token'), + setValue: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('getAccessToken', () => { + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should load the access token if available', async () => { + const accessToken = await netatmoHandler.getAccessToken(); + expect(accessToken).to.equal('valid_access_token'); + }); + + it('should return undefined and disconnect if no access token is available', async () => { + netatmoHandler.gladys.variable.getValue = fake.returns(null); + + const accessToken = await netatmoHandler.getAccessToken(); + expect(accessToken).to.equal(undefined); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + }); + + it('should throw an error if not configured', async () => { + netatmoHandler.gladys.variable.getValue = fake.rejects(new Error('Test error')); + + try { + await netatmoHandler.getAccessToken(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'not_initialized' }, + }), + ).to.equal(true); + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.equal('Netatmo is not configured.'); + } + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.getConfiguration.test.js b/server/test/services/netatmo/lib/netatmo.getConfiguration.test.js new file mode 100644 index 0000000000..e2860461d1 --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.getConfiguration.test.js @@ -0,0 +1,65 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const { EVENTS } = require('../../../../utils/constants'); +const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + getValue: sinon.fake((variableName, serviceId) => { + if (variableName === 'NETATMO_CLIENT_ID') { + return Promise.resolve('valid_client_id'); + } + if (variableName === 'NETATMO_CLIENT_SECRET') { + return Promise.resolve('valid_client_secret'); + } + return Promise.reject(new Error('Unknown variable')); + }), + setValue: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo getConfiguration', () => { + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should load the configuration if available', async () => { + const configuration = await netatmoHandler.getConfiguration(); + expect(configuration.clientId).to.equal('valid_client_id'); + expect(configuration.clientSecret).to.equal('valid_client_secret'); + }); + + it('should throw an error if not configured', async () => { + netatmoHandler.gladys.variable.getValue = fake.rejects(new Error('Test error')); + try { + await netatmoHandler.getConfiguration(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(netatmoHandler.configuration.clientId).to.equal('valid_client_id'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'not_initialized' }, + }), + ).to.equal(true); + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.equal('Netatmo is not configured.'); + } + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.getRefreshToken.test.js b/server/test/services/netatmo/lib/netatmo.getRefreshToken.test.js new file mode 100644 index 0000000000..bfc93fb442 --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.getRefreshToken.test.js @@ -0,0 +1,89 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const { EVENTS } = require('../../../../utils/constants'); +const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + getValue: sinon.fake((variableName, serviceId) => { + if (variableName === 'NETATMO_REFRESH_TOKEN') { + return Promise.resolve('valid_refresh_token'); + } + if (variableName === 'NETATMO_EXPIRE_IN_TOKEN') { + return Promise.resolve(10800); + } + return Promise.reject(new Error('Unknown variable')); + }), + setValue: sinon.stub().resolves(), + }, +}; +const serviceIdFake = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceIdFake); + +describe('getRefreshToken', () => { + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should load the refresh token if available', async () => { + const refreshToken = await netatmoHandler.getRefreshToken(); + expect(refreshToken).to.equal('valid_refresh_token'); + expect(netatmoHandler.refreshToken).to.equal('valid_refresh_token'); + expect(netatmoHandler.expireInToken).to.equal(10800); + }); + + it('should return undefined and disconnect if no refresh token is available', async () => { + netatmoHandler.gladys.variable.getValue = fake.returns(null); + + const refreshToken = await netatmoHandler.getRefreshToken(); + expect(refreshToken).to.equal(undefined); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + }); + + it('should throw an error if not configured', async () => { + netatmoHandler.gladys.variable.getValue = sinon.fake((variableName, serviceId) => { + if (variableName === 'NETATMO_REFRESH_TOKEN') { + return Promise.resolve('valid_refresh_token'); + } + if (variableName === 'NETATMO_EXPIRE_IN_TOKEN') { + return Promise.reject(new Error('Test error')); + } + return Promise.reject(new Error('Unknown variable')); + }); + + try { + await netatmoHandler.getRefreshToken(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(netatmoHandler.refreshToken).to.equal('valid_refresh_token'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'not_initialized' }, + }), + ).to.equal(true); + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.equal('Netatmo is not configured.'); + } + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.getStatus.test.js b/server/test/services/netatmo/lib/netatmo.getStatus.test.js new file mode 100644 index 0000000000..abc9d24827 --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.getStatus.test.js @@ -0,0 +1,34 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const { STATUS } = require('../../../../services/netatmo/lib/utils/netatmo.constants'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + stateManager: { + get: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo getStatus', () => { + it('should return the current status of Netatmo handler', () => { + netatmoHandler.configured = true; + netatmoHandler.connected = false; + netatmoHandler.status = STATUS.CONNECTED; + + const status = netatmoHandler.getStatus(); + + expect(status).to.deep.equal({ + configured: true, + connected: false, + status: 'connected', + }); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.init.test.js b/server/test/services/netatmo/lib/netatmo.init.test.js new file mode 100644 index 0000000000..ecc9fafed9 --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.init.test.js @@ -0,0 +1,90 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + getValue: sinon.fake((variableName, serviceId) => { + if (variableName === 'NETATMO_CLIENT_ID') { + return Promise.resolve('valid_client_id'); + } + if (variableName === 'NETATMO_CLIENT_SECRET') { + return Promise.resolve('valid_client_secret'); + } + if (variableName === 'NETATMO_ACCESS_TOKEN') { + return Promise.resolve('valid_access_token'); + } + if (variableName === 'NETATMO_REFRESH_TOKEN') { + return Promise.resolve('valid_refresh_token'); + } + if (variableName === 'NETATMO_EXPIRE_IN_TOKEN') { + return Promise.resolve(10800); + } + return Promise.reject(new Error('Unknown variable')); + }), + setValue: sinon.stub().resolves(), + }, +}; +const serviceIdFake = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceIdFake); +netatmoHandler.pollRefreshingToken = fake.resolves(null); +netatmoHandler.pollRefreshingValues = fake.resolves(null); + +describe('Netatmo Init', () => { + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + netatmoHandler.gladys.variable.getValue = sinon.fake((variableName, serviceId) => { + if (variableName === 'NETATMO_CLIENT_ID') { + return Promise.resolve('valid_client_id'); + } + if (variableName === 'NETATMO_CLIENT_SECRET') { + return Promise.resolve('valid_client_secret'); + } + if (variableName === 'NETATMO_ACCESS_TOKEN') { + return Promise.resolve('valid_access_token'); + } + if (variableName === 'NETATMO_REFRESH_TOKEN') { + return Promise.resolve('valid_refresh_token'); + } + if (variableName === 'NETATMO_EXPIRE_IN_TOKEN') { + return Promise.resolve(10800); + } + return Promise.reject(new Error('Unknown variable')); + }); + netatmoHandler.refreshingTokens = fake.resolves({ success: true }); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should handle valid access and refresh tokens', async () => { + netatmoHandler.configuration.clientId = 'valid_client_id'; + netatmoHandler.configuration.clientSecret = 'valid_client_secret'; + netatmoHandler.accessToken = 'valid_access_token'; + netatmoHandler.refreshToken = 'valid_refresh_token'; + + await netatmoHandler.init(); + + expect(netatmoHandler.refreshingTokens.called).to.equal(true); + expect(netatmoHandler.pollRefreshingToken.called).to.equal(true); + expect(netatmoHandler.pollRefreshingValues.called).to.equal(true); + }); + + it('should handle failed token refresh', async () => { + netatmoHandler.refreshingTokens = fake.resolves({ success: false }); + + await netatmoHandler.init(); + + expect(netatmoHandler.refreshingTokens.called).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(0); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.loadDeviceDetails.test.js b/server/test/services/netatmo/lib/netatmo.loadDeviceDetails.test.js new file mode 100644 index 0000000000..e104b16a74 --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.loadDeviceDetails.test.js @@ -0,0 +1,153 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const nock = require('nock'); + +const { fake } = sinon; + +const bodyHomesDataMock = require('../netatmo.homesdata.mock.test.json'); +const bodyHomeStatusMock = require('../netatmo.homestatus.mock.test.json'); +const thermostatsDetailsMock = require('../netatmo.loadThermostatDetails.mock.test.json'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + stateManager: { + get: sinon.stub().resolves(), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const accessToken = 'testAccessToken'; +const homesMock = bodyHomesDataMock.homes[0]; + +describe('Netatmo Load Device Details', () => { + beforeEach(() => { + sinon.reset(); + nock.cleanAll(); + + netatmoHandler.status = 'not_initialized'; + netatmoHandler.accessToken = accessToken; + netatmoHandler.loadThermostatDetails = sinon.stub().resolves(thermostatsDetailsMock); + }); + + afterEach(() => { + sinon.reset(); + nock.cleanAll(); + }); + + it('should load device details successfully', async () => { + nock('https://api.netatmo.com') + .get('/api/homestatus') + .reply(200, { body: bodyHomeStatusMock, status: 'ok' }); + const devices = await netatmoHandler.loadDeviceDetails(homesMock); + + expect(devices).to.have.lengthOf(4); + expect(devices.filter((device) => device.type === 'NATherm1')).to.have.lengthOf(1); + expect(devices.filter((device) => device.type === 'NAPlug')).to.have.lengthOf(2); + expect(devices.filter((device) => device.not_handled)).to.have.lengthOf(1); + const natThermDevices = devices.filter((device) => device.type === 'NATherm1'); + expect(natThermDevices).to.have.lengthOf.at.least(1); + natThermDevices.forEach((device) => { + expect(device.room).to.be.an('object'); + expect(device.room).to.not.deep.equal({}); + expect(device.plug).to.be.an('object'); + expect(device.plug).to.not.deep.equal({}); + expect(device.categoryAPI).to.be.eq('Energy'); + }); + const natPlugDevices = devices.filter((device) => device.type === 'NAPlug'); + expect(natPlugDevices).to.have.lengthOf.at.least(2); + }); + + it('should load device details successfully without thermostat', async () => { + const homesMockFake = JSON.parse(JSON.stringify(homesMock)); + homesMockFake.modules = homesMockFake.modules.filter((module) => module.type !== 'NATherm1'); + const bodyHomeStatusMockFake = JSON.parse(JSON.stringify(bodyHomeStatusMock)); + bodyHomeStatusMockFake.home.modules = bodyHomeStatusMock.home.modules.filter( + (module) => module.type !== 'NATherm1', + ); + netatmoHandler.loadThermostatDetails = sinon.stub().resolves([]); + + nock('https://api.netatmo.com') + .get('/api/homestatus') + .reply(200, { body: bodyHomeStatusMockFake, status: 'ok' }); + + const devices = await netatmoHandler.loadDeviceDetails(homesMockFake); + + expect(devices).to.have.lengthOf(3); + expect(devices.filter((device) => device.type === 'NATherm1')).to.have.lengthOf(0); + expect(devices.filter((device) => device.type === 'NAPlug')).to.have.lengthOf(2); + expect(devices.filter((device) => device.not_handled)).to.have.lengthOf(1); + expect(devices).to.be.an('array'); + }); + + it('should load device details successfully but without modules thermostat', async () => { + netatmoHandler.loadThermostatDetails = sinon.stub().resolves([]); + + nock('https://api.netatmo.com') + .get('/api/homestatus') + .reply(200, { body: bodyHomeStatusMock, status: 'ok' }); + + const devices = await netatmoHandler.loadDeviceDetails(homesMock); + + const natThermDevices = devices.filter((device) => device.type === 'NATherm1'); + expect(natThermDevices).to.have.lengthOf.at.least(1); + natThermDevices.forEach((device) => { + expect(device) + .to.have.property('plug') + .that.is.an('object'); + expect(device.plug).to.not.have.property('plug_connected_boiler'); + }); + }); + + it('should no load device details without modules', async () => { + const homesMockFake = JSON.parse(JSON.stringify(homesMock)); + homesMockFake.modules = homesMockFake.modules.filter((module) => module.type !== 'NATherm1'); + const bodyHomeStatusMockFake = { ...bodyHomeStatusMock }; + bodyHomeStatusMockFake.home.modules = undefined; + netatmoHandler.loadThermostatDetails = sinon.stub().resolves([]); + + nock('https://api.netatmo.com') + .get('/api/homestatus') + .reply(200, { body: bodyHomeStatusMockFake, status: 'ok' }); + + const devices = await netatmoHandler.loadDeviceDetails(homesMockFake); + + expect(devices).to.be.eq(undefined); + }); + + it('should handle API errors gracefully', async () => { + nock('https://api.netatmo.com') + .get('/api/homestatus') + .reply(400, { + error: { + code: { + type: 'number', + example: 21, + }, + message: { + type: 'string', + example: 'invalid [parameter]', + }, + }, + }); + + const devices = await netatmoHandler.loadDeviceDetails(homesMock); + + expect(devices).to.be.eq(undefined); + }); + + it('should handle unexpected API responses', async () => { + nock('https://api.netatmo.com') + .get('/api/homestatus') + .reply(200, { body: bodyHomeStatusMock, status: 'error' }); + + const devices = await netatmoHandler.loadDeviceDetails(homesMock); + expect(devices).to.be.an('array'); + expect(devices).to.have.lengthOf(0); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.loadDevices.test.js b/server/test/services/netatmo/lib/netatmo.loadDevices.test.js new file mode 100644 index 0000000000..74fc7e95c5 --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.loadDevices.test.js @@ -0,0 +1,129 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const nock = require('nock'); + +const { fake } = sinon; + +const deviceDetailsMock = require('../netatmo.loadDevicesDetails.mock.test.json'); +const bodyHomesDataMock = require('../netatmo.homesdata.mock.test.json'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + stateManager: { + get: sinon.stub().resolves(), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const accessToken = 'testAccessToken'; + +describe('Netatmo Load Devices', () => { + beforeEach(() => { + sinon.reset(); + nock.cleanAll(); + + netatmoHandler.status = 'not_initialized'; + netatmoHandler.accessToken = accessToken; + netatmoHandler.loadDeviceDetails = sinon.stub().resolves(deviceDetailsMock); + }); + + afterEach(() => { + sinon.reset(); + nock.cleanAll(); + }); + + it('should load devices successfully', async () => { + nock('https://api.netatmo.com') + .get('/api/homesdata') + .reply(200, { body: bodyHomesDataMock, status: 'ok' }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.be.an('array'); + }); + + it('should handle API errors gracefully', async () => { + nock('https://api.netatmo.com') + .get('/api/homesdata') + .reply(400, { + error: { + code: { + type: 'number', + example: 21, + }, + message: { + type: 'string', + example: 'invalid [parameter]', + }, + }, + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.be.eq(undefined); + }); + + it('should handle unexpected API responses', async () => { + nock('https://api.netatmo.com') + .get('/api/homesdata') + .reply(200, { + body: bodyHomesDataMock, + status: 'error', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.be.an('array'); + expect(devices).to.have.lengthOf(0); + }); + + it('should handle API errors gracefully', async () => { + const badBodyHomesData = JSON.parse(JSON.stringify(bodyHomesDataMock)); + badBodyHomesData.homes[0].modules = undefined; + badBodyHomesData.homes[1].modules = undefined; + nock('https://api.netatmo.com') + .get('/api/homesdata') + .reply(200, { + body: badBodyHomesData, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.deep.eq([]); + }); + + it('should return an empty array if no homes are returned from the API', async () => { + const bodyHomesDataEmpty = JSON.parse(JSON.stringify(bodyHomesDataMock)); + bodyHomesDataEmpty.homes = []; + + nock('https://api.netatmo.com') + .get('/api/homesdata') + .reply(200, { body: bodyHomesDataEmpty, status: 'ok' }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.deep.eq([]); + }); + + it('should return an empty array if homes are returned without modules', async () => { + const bodyHomesDataNoModules = JSON.parse(JSON.stringify(bodyHomesDataMock)); + bodyHomesDataNoModules.homes.forEach((home) => { + home.modules = undefined; + }); + + nock('https://api.netatmo.com') + .get('/api/homesdata') + .reply(200, { body: bodyHomesDataNoModules, status: 'ok' }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.deep.eq([]); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.loadThermostatDetails.test.js b/server/test/services/netatmo/lib/netatmo.loadThermostatDetails.test.js new file mode 100644 index 0000000000..a198c46abf --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.loadThermostatDetails.test.js @@ -0,0 +1,86 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const nock = require('nock'); + +const { fake } = sinon; + +const bodyGetThermostatMock = require('../netatmo.getThermostat.mock.test.json'); +const thermostatsDetailsMock = require('../netatmo.loadThermostatDetails.mock.test.json'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + stateManager: { + get: sinon.stub().resolves(), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const accessToken = 'testAccessToken'; + +describe('Netatmo Load Thermostat Details', () => { + beforeEach(() => { + sinon.reset(); + nock.cleanAll(); + + netatmoHandler.status = 'not_initialized'; + netatmoHandler.accessToken = accessToken; + }); + + afterEach(() => { + sinon.reset(); + nock.cleanAll(); + }); + + it('should load thermostat details successfully', async () => { + nock('https://api.netatmo.com') + .get('/api/getthermostatsdata') + .reply(200, { body: bodyGetThermostatMock, status: 'ok' }); + + const { thermostats, modules } = await netatmoHandler.loadThermostatDetails(); + expect(thermostats).to.deep.eq(thermostatsDetailsMock.thermostats); + expect(modules).to.deep.eq(thermostatsDetailsMock.modules); + expect(thermostats).to.be.an('array'); + expect(modules).to.be.an('array'); + }); + + it('should handle API errors gracefully', async () => { + nock('https://api.netatmo.com') + .get('/api/getthermostatsdata') + .reply(400, { + error: { + code: { + type: 'number', + example: 21, + }, + message: { + type: 'string', + example: 'invalid [parameter]', + }, + }, + }); + + const { thermostats, modules } = await netatmoHandler.loadThermostatDetails(); + + expect(thermostats).to.be.eq(undefined); + expect(modules).to.be.eq(undefined); + }); + + it('should handle unexpected API responses', async () => { + nock('https://api.netatmo.com') + .get('/api/getthermostatsdata') + .reply(200, { body: bodyGetThermostatMock, status: 'error' }); + + const { thermostats, modules } = await netatmoHandler.loadThermostatDetails(); + expect(thermostats).to.deep.eq(thermostatsDetailsMock.thermostats); + expect(modules).to.deep.eq([]); + expect(thermostats).to.be.an('array'); + expect(modules).to.be.an('array'); + expect(modules).to.have.lengthOf(0); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.pollRefreshingTokens.test.js b/server/test/services/netatmo/lib/netatmo.pollRefreshingTokens.test.js new file mode 100644 index 0000000000..8a766c37a9 --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.pollRefreshingTokens.test.js @@ -0,0 +1,137 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const nock = require('nock'); + +const { fake } = sinon; + +const { EVENTS } = require('../../../../utils/constants'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + stateManager: { + get: sinon.stub().resolves(), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const { refreshingTokens } = netatmoHandler; + +describe('Netatmo pollRefreshingToken', () => { + let clock; + let tokens; + + beforeEach(() => { + sinon.reset(); + nock.cleanAll(); + + clock = sinon.useFakeTimers(); + netatmoHandler.status = 'not_initialized'; + netatmoHandler.configuration = { + clientId: 'valid_client_id', + clientSecret: 'valid_client_secret', + scopes: { scopeEnergy: 'scope' }, + }; + netatmoHandler.accessToken = 'valid_access_token'; + netatmoHandler.refreshToken = 'valid_refresh_token'; + netatmoHandler.expireInToken = 3600; + tokens = { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expire_in: 3600, + }; + }); + + afterEach(() => { + clock.restore(); + sinon.reset(); + nock.cleanAll(); + }); + + it('should refresh tokens periodically', async () => { + netatmoHandler.refreshingTokens = sinon.stub().resolves({ success: true }); + + await netatmoHandler.pollRefreshingToken(); + + clock.tick(3600 * 1000); + sinon.assert.calledOnce(netatmoHandler.refreshingTokens); + + netatmoHandler.refreshingTokens = refreshingTokens; + tokens.refresh_token = 'new-refresh-token2'; + nock('https://api.netatmo.com') + .post('/oauth2/token') + .reply(200, tokens); + + clock.tick(3600 * 1000); + clock.restore(); + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(netatmoHandler.accessToken).to.equal('new-access-token'); + expect(netatmoHandler.refreshToken).to.equal('new-refresh-token2'); + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'processing token' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }), + ).to.equal(true); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_ACCESS_TOKEN', + 'new-access-token', + serviceId, + ); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_REFRESH_TOKEN', + 'new-refresh-token2', + serviceId, + ); + sinon.assert.calledWith(netatmoHandler.gladys.variable.setValue, 'NETATMO_EXPIRE_IN_TOKEN', 3600, serviceId); + }); + + it('should handle token expiration change correctly', async () => { + netatmoHandler.refreshingTokens = sinon.stub().callsFake(async () => { + netatmoHandler.expireInToken = 7200; + return { success: true }; + }); + const initialExpireInToken = netatmoHandler.expireInToken; + const pollRefreshingTokenSpy = sinon.spy(netatmoHandler, 'pollRefreshingToken'); + const pollRefreshTokenId = netatmoHandler.pollRefreshToken.id; + + await netatmoHandler.pollRefreshingToken(); + + clock.tick(initialExpireInToken * 1000); + expect(netatmoHandler.pollRefreshToken.id).to.equal(pollRefreshTokenId + 1); + expect(netatmoHandler.pollRefreshingToken.callCount).to.equal(1); + sinon.assert.calledOnce(pollRefreshingTokenSpy); + + pollRefreshingTokenSpy.restore(); + netatmoHandler.refreshingTokens = refreshingTokens; + }); + + it('should not set interval if expireInToken is null', async () => { + netatmoHandler.expireInToken = null; + const refreshingTokensSpy = sinon.spy(netatmoHandler, 'refreshingTokens'); + + await netatmoHandler.pollRefreshingToken(); + + sinon.assert.notCalled(refreshingTokensSpy); + + refreshingTokensSpy.restore(); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.pollRefreshingValues.test.js b/server/test/services/netatmo/lib/netatmo.pollRefreshingValues.test.js new file mode 100644 index 0000000000..4d4233bcf8 --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.pollRefreshingValues.test.js @@ -0,0 +1,120 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesMock = require('../netatmo.loadDevices.mock.test.json'); +const devicesExistsMock = require('../netatmo.discoverDevices.mock.test.json'); +const { EVENTS } = require('../../../../utils/constants'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + stateManager: { + get: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo pollRefreshingValuess', () => { + let clock; + + beforeEach(() => { + sinon.reset(); + + clock = sinon.useFakeTimers(); + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + clock.restore(); + sinon.reset(); + }); + + it('should refresh device values directly', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.loadDevices = sinon.stub().resolves(devicesMock); + netatmoHandler.gladys.stateManager.get = sinon.stub().returns(devicesExistsMock); + + await netatmoHandler.pollRefreshingValues(); + + sinon.assert.calledOnce(netatmoHandler.loadDevices); + clock.restore(); + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'get devices values' }, + }); + expect(netatmoHandler.status).to.equal('connected'); + }); + + it('should refresh device values periodically', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.loadDevices = sinon.stub().resolves(devicesMock); + netatmoHandler.gladys.stateManager.get = sinon.stub().returns(devicesExistsMock); + + await netatmoHandler.pollRefreshingValues(); + + clock.tick(120 * 1000); + clock.restore(); + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 50)); + sinon.assert.calledTwice(netatmoHandler.loadDevices); + }); + + it('should refresh device values periodically without device existing in Gladys', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.loadDevices = sinon.stub().resolves(devicesMock); + + await netatmoHandler.pollRefreshingValues(); + + clock.restore(); + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 50)); + sinon.assert.called(netatmoHandler.loadDevices); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'get devices values' }, + }); + }); + + it('should handle an error during device loading', async () => { + netatmoHandler.loadDevices = sinon.stub().rejects(new Error('Failed to load devices')); + + await netatmoHandler.pollRefreshingValues(); + + clock.restore(); + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 50)); + sinon.assert.called(netatmoHandler.loadDevices); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(4); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'get devices values' }, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.error-connected', + payload: { status: 'get_devices_value_fail', statusType: 'connected' }, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'get devices values' }, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.refreshingTokens.test.js b/server/test/services/netatmo/lib/netatmo.refreshingTokens.test.js new file mode 100644 index 0000000000..d97aa1bd04 --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.refreshingTokens.test.js @@ -0,0 +1,183 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const nock = require('nock'); + +const { fake } = sinon; + +const { EVENTS } = require('../../../../utils/constants'); +const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo Refreshing Tokens', () => { + beforeEach(() => { + sinon.reset(); + nock.cleanAll(); + + netatmoHandler.configuration = { + clientId: 'valid_client_id', + clientSecret: 'valid_client_secret', + scopes: { scopeEnergy: 'scope' }, + }; + netatmoHandler.accessToken = 'valid_access_token'; + netatmoHandler.refreshToken = 'valid_refresh_token'; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + nock.cleanAll(); + }); + + it('should throw an error if configuration are missing', async () => { + netatmoHandler.configuration.clientId = null; + netatmoHandler.configuration.clientSecret = null; + + try { + await netatmoHandler.refreshingTokens(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.equal('Netatmo is not configured.'); + expect(netatmoHandler.status).to.equal('not_initialized'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'not_initialized' }, + }), + ).to.equal(true); + } + }); + + it('should throw an error if tokens are missing', async () => { + netatmoHandler.accessToken = null; + netatmoHandler.refreshToken = null; + netatmoHandler.configuration.clientId = 'valid_client_id'; + netatmoHandler.configuration.clientSecret = 'valid_client_secret'; + + try { + await netatmoHandler.refreshingTokens(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.equal('Netatmo is not connected.'); + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + } + }); + + it('should successfully refresh tokens', async () => { + const tokens = { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expire_in: 3600, + }; + nock('https://api.netatmo.com') + .post('/oauth2/token') + .reply(200, tokens); + + const result = await netatmoHandler.refreshingTokens(); + expect(result).to.deep.equal({ success: true }); + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'processing token' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }), + ).to.equal(true); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_ACCESS_TOKEN', + 'new-access-token', + serviceId, + ); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_REFRESH_TOKEN', + 'new-refresh-token', + serviceId, + ); + sinon.assert.calledWith(netatmoHandler.gladys.variable.setValue, 'NETATMO_EXPIRE_IN_TOKEN', 3600, serviceId); + }); + + it('should handle an error during token refresh', async () => { + nock('https://api.netatmo.com') + .post('/oauth2/token') + .reply(400, { error: 'invalid_request' }); + + try { + await netatmoHandler.refreshingTokens(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.include('NETATMO: Service is not connected with error'); + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(3); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'processing token' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.error-processing-token', + payload: { statusType: 'processing token', status: 'refresh_token_fail' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + } + }); + + it('should handle errors without a response object', async () => { + netatmoHandler.configuration.clientId = 'test-client-id'; + netatmoHandler.configuration.clientSecret = 'test-client-secret'; + netatmoHandler.configuration.scopes = { scopeEnergy: 'scope' }; + netatmoHandler.refreshToken = 'refresh-token'; + nock('https://api.netatmo.com') + .post('/oauth2/token') + .replyWithError('Network error'); + + try { + await netatmoHandler.refreshingTokens(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.include('NETATMO: Service is not connected with error'); + expect(e.response).to.equal(undefined); + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(3); + } + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.retrieveToken.test.js b/server/test/services/netatmo/lib/netatmo.retrieveToken.test.js new file mode 100644 index 0000000000..6f24bd81b6 --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.retrieveToken.test.js @@ -0,0 +1,172 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const nock = require('nock'); + +const { fake } = sinon; + +const { EVENTS } = require('../../../../utils/constants'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +netatmoHandler.pollRefreshingToken = fake.resolves(null); +netatmoHandler.pollRefreshingValues = fake.resolves(null); + +describe('Netatmo retrieveTokens', () => { + let body; + beforeEach(() => { + sinon.reset(); + nock.cleanAll(); + + body = { codeOAuth: 'test-code', state: 'valid-state', redirectUri: 'test-redirect-uri' }; + netatmoHandler.configuration.clientId = 'clientId-fake'; + netatmoHandler.configuration.clientSecret = 'clientSecret-fake'; + netatmoHandler.stateGetAccessToken = 'valid-state'; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + nock.cleanAll(); + }); + + it('should throw an error if configuration is not complete', async () => { + netatmoHandler.configuration.clientId = null; + + try { + await netatmoHandler.retrieveTokens(body); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e.message).to.equal('Netatmo is not configured.'); + expect(netatmoHandler.status).to.equal('not_initialized'); + expect(netatmoHandler.configured).to.equal(false); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'not_initialized' }, + }), + ).to.equal(true); + } + }); + + it('should throw an error if state does not match', async () => { + body = { codeOAuth: 'test-code', state: 'invalid-state', redirectUri: 'test-redirect-uri' }; + + try { + await netatmoHandler.retrieveTokens(body); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e.message).to.include( + 'Netatmo did not connect correctly. The return does not correspond to the initial request', + ); + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + } + }); + + it('should retrieve tokens and update netatmo status if state matches', async () => { + const tokens = { + access_token: 'access-token', + refresh_token: 'refresh-token', + expire_in: 3600, + }; + nock('https://api.netatmo.com') + .persist() + .post('/oauth2/token') + .reply(200, tokens); + + const result = await netatmoHandler.retrieveTokens(body); + + expect(result).to.deep.equal({ success: true }); + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'processing token' }, + }), + ).to.equal(true); + }); + + it('should throw an error when axios request fails', async () => { + const bodyFake = { codeOAuth: 'test-code', state: 'valid-state', redirectUri: 'test-redirect-uri' }; + netatmoHandler.configuration.clientId = 'test-client-id'; + netatmoHandler.configuration.clientSecret = 'test-client-secret'; + netatmoHandler.configuration.scopes = { scopeEnergy: 'scope' }; + netatmoHandler.stateGetAccessToken = 'valid-state'; + nock('https://api.netatmo.com') + .post('/oauth2/token') + .reply(400, { error: 'invalid_request' }); + + try { + await netatmoHandler.retrieveTokens(bodyFake); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e.message).to.include('Service is not connected with error'); + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(3); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'processing token' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.error-processing-token', + payload: { statusType: 'processing token', status: 'get_access_token_fail' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + } + }); + + it('should handle errors without a response object', async () => { + const bodyFake = { codeOAuth: 'test-code', state: 'valid-state', redirectUri: 'test-redirect-uri' }; + netatmoHandler.configuration.clientId = 'test-client-id'; + netatmoHandler.configuration.clientSecret = 'test-client-secret'; + netatmoHandler.configuration.scopes = { scopeEnergy: 'scope' }; + netatmoHandler.stateGetAccessToken = 'valid-state'; + nock('https://api.netatmo.com') + .post('/oauth2/token') + .replyWithError('Network error'); + + try { + await netatmoHandler.retrieveTokens(bodyFake); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e.message).to.include('NETATMO: Service is not connected with error'); + expect(e.response).to.equal(undefined); + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(false); + } + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.saveConfiguration.test.js b/server/test/services/netatmo/lib/netatmo.saveConfiguration.test.js new file mode 100644 index 0000000000..a06bd34836 --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.saveConfiguration.test.js @@ -0,0 +1,73 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + variable: { + setValue: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo Save configuration', () => { + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should successfully save the configuration', async () => { + const testConfig = { + clientId: 'new-client-id', + clientSecret: 'new-client-secret', + }; + const result = await netatmoHandler.saveConfiguration(testConfig); + + expect(result).to.equal(true); + expect(netatmoHandler.configuration.clientId).to.equal('new-client-id'); + expect(netatmoHandler.configuration.clientSecret).to.equal('new-client-secret'); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_CLIENT_ID', + 'new-client-id', + netatmoHandler.serviceId, + ); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_CLIENT_SECRET', + 'new-client-secret', + netatmoHandler.serviceId, + ); + }); + + it('should handle an error during configuration save', async () => { + const testConfig = { + clientId: 'new-client-id', + clientSecret: 'new-client-secret', + }; + netatmoHandler.gladys.variable.setValue + .withArgs('NETATMO_CLIENT_ID', sinon.match.any) + .throws(new Error('Failed to save')); + const result = await netatmoHandler.saveConfiguration(testConfig); + + expect(result).to.equal(false); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_CLIENT_ID', + 'new-client-id', + netatmoHandler.serviceId, + ); + sinon.assert.neverCalledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_CLIENT_SECRET', + 'new-client-secret', + netatmoHandler.serviceId, + ); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.saveStatus.test.js b/server/test/services/netatmo/lib/netatmo.saveStatus.test.js new file mode 100644 index 0000000000..174e0eaef5 --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.saveStatus.test.js @@ -0,0 +1,298 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { assert, fake } = sinon; + +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); +const { STATUS } = require('../../../../services/netatmo/lib/utils/netatmo.constants'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; + +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo saveStatus', () => { + let clock; + + beforeEach(() => { + sinon.reset(); + clock = sinon.useFakeTimers(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + clock.restore(); + sinon.reset(); + }); + + it('should update the status to NOT_INITIALIZED and emit the event', () => { + sinon.spy(clock, 'clearInterval'); + const intervalPollRefreshTokenSpy = sinon.spy(); + const intervalPollRefreshValuesSpy = sinon.spy(); + netatmoHandler.pollRefreshToken = setInterval(intervalPollRefreshTokenSpy, 3600); + netatmoHandler.pollRefreshValues = setInterval(intervalPollRefreshValuesSpy, 120); + + netatmoHandler.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + + expect(netatmoHandler.status).to.equal('not_initialized'); + expect(netatmoHandler.configured).to.equal(false); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'not_initialized' }, + }); + clock.tick(3600 * 1000 * 2); + assert.calledTwice(clock.clearInterval); + expect(intervalPollRefreshTokenSpy.notCalled).to.equal(true); + expect(intervalPollRefreshValuesSpy.notCalled).to.equal(true); + }); + it('should update the status to CONNECTING and emit the event', () => { + netatmoHandler.saveStatus({ statusType: STATUS.CONNECTING, message: null }); + + expect(netatmoHandler.status).to.equal('connecting'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connecting' }, + }); + }); + it('should update the status to PROCESSING_TOKEN and emit the event', () => { + netatmoHandler.saveStatus({ statusType: STATUS.PROCESSING_TOKEN, message: null }); + + expect(netatmoHandler.status).to.equal('processing token'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'processing token' }, + }); + }); + it('should update the status to CONNECTED and emit the event', () => { + netatmoHandler.saveStatus({ statusType: STATUS.CONNECTED, message: null }); + + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }); + }); + it('should update the status to DISCONNECTING and emit the event', () => { + netatmoHandler.saveStatus({ statusType: STATUS.DISCONNECTING, message: null }); + + expect(netatmoHandler.status).to.equal('disconnecting'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnecting' }, + }); + }); + it('should update the status to DISCONNECTED and emit the event', () => { + sinon.spy(clock, 'clearInterval'); + const intervalPollRefreshTokenSpy = sinon.spy(); + const intervalPollRefreshValuesSpy = sinon.spy(); + netatmoHandler.pollRefreshToken = setInterval(intervalPollRefreshTokenSpy, 3600); + netatmoHandler.pollRefreshValues = setInterval(intervalPollRefreshValuesSpy, 120); + + netatmoHandler.saveStatus({ statusType: STATUS.DISCONNECTED, message: null }); + + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }); + clock.tick(3600 * 1000 * 2); + assert.calledTwice(clock.clearInterval); + expect(intervalPollRefreshTokenSpy.notCalled).to.equal(true); + expect(intervalPollRefreshValuesSpy.notCalled).to.equal(true); + }); + it('should update the status to DISCOVERING_DEVICES and emit the event', () => { + netatmoHandler.saveStatus({ statusType: STATUS.DISCOVERING_DEVICES, message: null }); + + expect(netatmoHandler.status).to.equal('discovering'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'discovering' }, + }); + }); + it('should update the status to GET_DEVICES_VALUES and emit the event', () => { + netatmoHandler.saveStatus({ statusType: STATUS.GET_DEVICES_VALUES, message: null }); + + expect(netatmoHandler.status).to.equal('get devices values'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'get devices values' }, + }); + }); + + it('should update the status to ERROR CONNECTING and emit the event', () => { + netatmoHandler.saveStatus({ + statusType: 'error connecting', + message: 'error_connecting', + }); + + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.CONNECTING, + payload: { statusType: 'connecting', status: 'error_connecting' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.STATUS, + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }); + }); + + it('should update the status to ERROR PROCESSING_TOKEN and emit the event', () => { + netatmoHandler.saveStatus({ + statusType: 'error processing token', + message: 'get_access_token_fail', + }); + + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.PROCESSING_TOKEN, + payload: { statusType: 'processing token', status: 'get_access_token_fail' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.STATUS, + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }); + }); + + it('should update the status to ERROR CONNECTED and emit the event', () => { + netatmoHandler.saveStatus({ + statusType: 'error connected', + message: 'error_connected', + }); + + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.CONNECTED, + payload: { statusType: 'connected', status: 'error_connected' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }); + }); + + it('should update the status to ERROR SET_DEVICES_VALUES and emit the event', () => { + netatmoHandler.status = 'connected'; + netatmoHandler.saveStatus({ + statusType: 'error set devices values', + message: 'error_set_devices_values', + }); + + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.error-connected', + payload: { statusType: 'connected', status: 'error_set_devices_values' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }), + ).to.equal(true); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }); + }); + + it('should update the status to ERROR GET_DEVICES_VALUES and emit the event', () => { + netatmoHandler.status = 'connected'; + netatmoHandler.saveStatus({ + statusType: 'error get devices values', + message: 'error_get_devices_values', + }); + + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.error-connected', + payload: { statusType: 'connected', status: 'error_get_devices_values' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }), + ).to.equal(true); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }); + }); + + it('should handle unknown status types gracefully', () => { + const result = netatmoHandler.saveStatus({ statusType: 'unknown_status', message: null }); + + expect(result).to.equal(true); + }); + + it('should return false on error', () => { + netatmoHandler.gladys.event.emit = sinon.stub().throws(new Error('Emit failed')); + + const result = netatmoHandler.saveStatus({ statusType: STATUS.CONNECTED, message: null }); + expect(result).to.equal(false); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.setTokens.test.js b/server/test/services/netatmo/lib/netatmo.setTokens.test.js new file mode 100644 index 0000000000..3cd2067d7c --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.setTokens.test.js @@ -0,0 +1,89 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + variable: { + setValue: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo Set Token', () => { + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should successfully save tokens', async () => { + const testTokens = { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expireIn: 3600, + }; + + const result = await netatmoHandler.setTokens(testTokens); + + expect(result).to.equal(true); + expect(netatmoHandler.accessToken).to.equal('new-access-token'); + expect(netatmoHandler.refreshToken).to.equal('new-refresh-token'); + expect(netatmoHandler.expireInToken).to.equal(3600); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_ACCESS_TOKEN', + 'new-access-token', + netatmoHandler.serviceId, + ); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_REFRESH_TOKEN', + 'new-refresh-token', + netatmoHandler.serviceId, + ); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_EXPIRE_IN_TOKEN', + 3600, + netatmoHandler.serviceId, + ); + }); + + it('should handle an error during token save', async () => { + const testTokens = { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expireIn: 3600, + }; + + netatmoHandler.gladys.variable.setValue + .withArgs('NETATMO_REFRESH_TOKEN', sinon.match.any) + .throws(new Error('Failed to save')); + + const result = await netatmoHandler.setTokens(testTokens); + + expect(result).to.equal(false); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_ACCESS_TOKEN', + 'new-access-token', + netatmoHandler.serviceId, + ); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_REFRESH_TOKEN', + 'new-refresh-token', + netatmoHandler.serviceId, + ); + sinon.assert.neverCalledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_EXPIRE_IN_TOKEN', + 3600, + netatmoHandler.serviceId, + ); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.setValue.test.js b/server/test/services/netatmo/lib/netatmo.setValue.test.js new file mode 100644 index 0000000000..221d00b77f --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.setValue.test.js @@ -0,0 +1,180 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const nock = require('nock'); + +const { fake } = sinon; + +const devicesMock = require('../netatmo.convertDevices.mock.test.json'); +const { EVENTS } = require('../../../../utils/constants'); +const { BadParameters } = require('../../../../utils/coreErrors'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const [deviceMock] = devicesMock.filter((device) => device.model === 'NATherm1'); + +describe('Netatmo Set Value', () => { + beforeEach(() => { + sinon.reset(); + nock.cleanAll(); + + netatmoHandler.status = 'connected'; + netatmoHandler.accessToken = 'valid_access_token'; + }); + + afterEach(() => { + sinon.reset(); + nock.cleanAll(); + }); + + it('should set device value successfully', async () => { + const deviceFeatureMock = deviceMock.features.filter((feature) => + feature.external_id.includes('therm_setpoint_temperature'), + )[0]; + const newValue = 20; + + nock('https://api.netatmo.com') + .post('/api/setroomthermpoint') + .reply(200, {}); + + await netatmoHandler.setValue(deviceMock, deviceFeatureMock, newValue); + }); + + it('should throw an error if not home ID parameter', async () => { + const deviceMockFake = JSON.parse(JSON.stringify(deviceMock)); + const deviceFeatureMock = deviceMockFake.features.filter((feature) => + feature.external_id.includes('therm_setpoint_temperature'), + )[0]; + const externalId = deviceFeatureMock.external_id; + deviceMockFake.params = deviceMockFake.params.filter((oneParam) => oneParam.name === 'home_id'); + const newValue = 20; + + try { + await netatmoHandler.setValue(deviceMockFake, deviceFeatureMock, newValue); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(BadParameters); + expect(e.message).to.equal( + `Netatmo device external_id: "${externalId}" should contains parameters "HOME_ID" and "ROOM_ID"`, + ); + } + }); + + it('should throw an error if bad externalId prefix', async () => { + const deviceMockFake = JSON.parse(JSON.stringify(deviceMock)); + const deviceFeatureMock = deviceMockFake.features.filter((feature) => + feature.external_id.includes('therm_setpoint_temperature'), + )[0]; + const externalIdFake = deviceFeatureMock.external_id.replace('netatmo:', ''); + deviceFeatureMock.external_id = externalIdFake; + const newValue = 20; + + try { + await netatmoHandler.setValue(deviceMockFake, deviceFeatureMock, newValue); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(BadParameters); + expect(e.message).to.equal( + `Netatmo device external_id is invalid: "${externalIdFake}" should starts with "netatmo:"`, + ); + } + }); + + it('should throw an error if no externalId topic', async () => { + const deviceMockFake = JSON.parse(JSON.stringify(deviceMock)); + const deviceFeatureMock = deviceMockFake.features.filter((feature) => + feature.external_id.includes('therm_setpoint_temperature'), + )[0]; + const externalIdFake = 'netatmo'; + deviceFeatureMock.external_id = externalIdFake; + const newValue = 20; + + try { + await netatmoHandler.setValue(deviceMockFake, deviceFeatureMock, newValue); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(BadParameters); + expect(e.message).to.equal( + `Netatmo device external_id is invalid: "${externalIdFake}" have no id and category indicator`, + ); + } + }); + + it('should handle API errors gracefully with error 400', async () => { + const deviceFeatureMock = deviceMock.features.filter((feature) => + feature.external_id.includes('therm_setpoint_temperature'), + )[0]; + const newValue = 20; + + nock('https://api.netatmo.com') + .post('/api/setroomthermpoint') + .reply(400, { + error: { + code: { + type: 'number', + example: 21, + }, + message: { + type: 'string', + example: 'invalid [parameter]', + }, + }, + }); + + await netatmoHandler.setValue(deviceMock, deviceFeatureMock, newValue); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.error-connected', + payload: { statusType: 'connected', status: 'set_devices_value_error_unknown' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }), + ).to.equal(true); + }); + + it('should handle API errors gracefully with error 403 and code 13', async () => { + const deviceFeatureMock = deviceMock.features.filter((feature) => + feature.external_id.includes('therm_setpoint_temperature'), + )[0]; + const newValue = 20; + + nock('https://api.netatmo.com') + .post('/api/setroomthermpoint') + .reply(403, { + error: { + code: 13, + message: 'invalid [parameter]', + }, + }); + + await netatmoHandler.setValue(deviceMock, deviceFeatureMock, newValue); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.error-connected', + payload: { statusType: 'connected', status: 'set_devices_value_fail_scope_rights' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }), + ).to.equal(true); + }); +}); diff --git a/server/test/services/netatmo/lib/netatmo.updateValues.test.js b/server/test/services/netatmo/lib/netatmo.updateValues.test.js new file mode 100644 index 0000000000..364e4f7423 --- /dev/null +++ b/server/test/services/netatmo/lib/netatmo.updateValues.test.js @@ -0,0 +1,96 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = require('../netatmo.convertDevices.mock.test.json'); +const devicesNetatmo = require('../netatmo.loadDevices.mock.test.json'); +const { BadParameters } = require('../../../../utils/coreErrors'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; + +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo update features type', () => { + const deviceGladysNAPlug = JSON.parse(JSON.stringify(devicesGladys[0])); + const deviceGladysNATherm1 = JSON.parse(JSON.stringify(devicesGladys[1])); + const deviceGladysNotSupported = devicesGladys[devicesGladys.length - 1]; + const deviceNetatmoNAPlug = devicesNetatmo[0]; + const deviceNetatmoNATherm1 = devicesNetatmo[1]; + const deviceNetatmoNotSupported = devicesNetatmo[devicesGladys.length - 1]; + const externalIdNAPlug = `netatmo:${deviceNetatmoNAPlug.id}`; + const externalIdNATherm1 = `netatmo:${deviceNetatmoNATherm1.id}`; + const externalIdNotSupported = `netatmo:${deviceNetatmoNotSupported.id}`; + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should not update values if type not supported', async () => { + deviceGladysNAPlug.features = []; + await netatmoHandler.updateValues(deviceGladysNotSupported, deviceNetatmoNotSupported, externalIdNotSupported); + + sinon.assert.notCalled(netatmoHandler.gladys.event.emit); + }); + + it('should not update values if feature not found', async () => { + deviceGladysNAPlug.features = []; + await netatmoHandler.updateValues(deviceGladysNAPlug, deviceNetatmoNAPlug, externalIdNAPlug); + + sinon.assert.notCalled(netatmoHandler.gladys.event.emit); + }); + + it('should handle invalid external_id format on prefix', async () => { + const externalIdFake = deviceGladysNAPlug.external_id.replace('netatmo:', ''); + + try { + await netatmoHandler.updateValues(deviceGladysNAPlug, deviceNetatmoNAPlug, externalIdFake); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(BadParameters); + expect(e.message).to.equal( + `Netatmo device external_id is invalid: "${externalIdFake}" should starts with "netatmo:"`, + ); + } + }); + + it('should handle invalid external_id format on topic', async () => { + const externalIdFake = 'netatmo:'; + + try { + await netatmoHandler.updateValues(deviceGladysNAPlug, deviceNetatmoNAPlug, externalIdFake); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(BadParameters); + expect(e.message).to.equal( + `Netatmo device external_id is invalid: "${externalIdFake}" have no id and category indicator`, + ); + } + }); + + it('should update device values for a plug device', async () => { + await netatmoHandler.updateValues(deviceGladysNAPlug, deviceNetatmoNAPlug, externalIdNAPlug); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysNAPlug.features.length); + }); + + it('should save all values according to all cases', async () => { + await netatmoHandler.updateValues(deviceGladysNATherm1, deviceNetatmoNATherm1, externalIdNATherm1); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(7); + }); +}); diff --git a/server/test/services/netatmo/netatmo.convertDevices.mock.test.json b/server/test/services/netatmo/netatmo.convertDevices.mock.test.json new file mode 100644 index 0000000000..20658eb19e --- /dev/null +++ b/server/test/services/netatmo/netatmo.convertDevices.mock.test.json @@ -0,0 +1,257 @@ +[ + { + "name": "Relais Test", + "external_id": "netatmo:70:ee:50:xx:xx:xx", + "selector": "netatmo:70:ee:50:xx:xx:xx", + "model": "NAPlug", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Link RF quality - Relais Test", + "external_id": "netatmo:70:ee:50:xx:xx:xx:rf_strength", + "selector": "netatmo:70:ee:50:xx:xx:xx:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link Wifi quality - Relais Test", + "external_id": "netatmo:70:ee:50:xx:xx:xx:wifi_strength", + "selector": "netatmo:70:ee:50:xx:xx:xx:wifi_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Relais Test connected boiler", + "external_id": "netatmo:70:ee:50:xx:xx:xx:plug_connected_boiler", + "selector": "netatmo:70:ee:50:xx:xx:xx:plug_connected_boiler", + "category": "switch", + "type": "binary", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 1 + } + ], + "params": [ + { + "name": "modules_bridge_id", + "value": "[\"01:00:00:xx:xx:xx\",\"02:00:00:xx:xx:xx\"]" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "1234567890" + }, + { + "name": "room_name", + "value": "Garage Test" + } + ] + }, + { + "name": "Thermostat Test", + "external_id": "netatmo:04:00:00:xx:xx:xx", + "selector": "netatmo:04:00:00:xx:xx:xx", + "model": "NATherm1", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Battery - Thermostat Test", + "external_id": "netatmo:04:00:00:xx:xx:xx:battery_percent", + "selector": "netatmo:04:00:00:xx:xx:xx:battery_percent", + "category": "battery", + "type": "integer", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Temperature - Thermostat Test", + "external_id": "netatmo:04:00:00:xx:xx:xx:temperature", + "selector": "netatmo:04:00:00:xx:xx:xx:temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - room Maison Test", + "external_id": "netatmo:04:00:00:xx:xx:xx:therm_measured_temperature", + "selector": "netatmo:04:00:00:xx:xx:xx:therm_measured_temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Setpoint temperature - Thermostat Test", + "external_id": "netatmo:04:00:00:xx:xx:xx:therm_setpoint_temperature", + "selector": "netatmo:04:00:00:xx:xx:xx:therm_setpoint_temperature", + "category": "thermostat", + "type": "target-temperature", + "unit": "celsius", + "read_only": false, + "keep_history": true, + "has_feedback": false, + "min": 5, + "max": 30 + }, + { + "name": "Detecting open window - Thermostat Test", + "external_id": "netatmo:04:00:00:xx:xx:xx:open_window", + "selector": "netatmo:04:00:00:xx:xx:xx:open_window", + "category": "opening-sensor", + "type": "binary", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 1 + }, + { + "name": "Link RF quality - Thermostat Test", + "external_id": "netatmo:04:00:00:xx:xx:xx:rf_strength", + "selector": "netatmo:04:00:00:xx:xx:xx:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Boiler status - Thermostat Test", + "external_id": "netatmo:04:00:00:xx:xx:xx:boiler_status", + "selector": "netatmo:04:00:00:xx:xx:xx:boiler_status", + "category": "switch", + "type": "binary", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 1 + } + ], + "params": [ + { + "name": "plug_id", + "value": "70:ee:50:xx:xx:xx" + }, + { + "name": "plug_name", + "value": "Relais Test" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "0987654321" + }, + { + "name": "room_name", + "value": "Maison Test" + } + ] + }, + { + "name": "Relais Salon Test", + "external_id": "netatmo:70:ee:50:yy:yy:yy", + "selector": "netatmo:70:ee:50:yy:yy:yy", + "model": "NAPlug", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Link RF quality - Relais Salon Test", + "external_id": "netatmo:70:ee:50:yy:yy:yy:rf_strength", + "selector": "netatmo:70:ee:50:yy:yy:yy:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link Wifi quality - Relais Salon Test", + "external_id": "netatmo:70:ee:50:yy:yy:yy:wifi_strength", + "selector": "netatmo:70:ee:50:yy:yy:yy:wifi_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Relais Salon Test connected boiler", + "external_id": "netatmo:70:ee:50:yy:yy:yy:plug_connected_boiler", + "selector": "netatmo:70:ee:50:yy:yy:yy:plug_connected_boiler", + "category": "switch", + "type": "binary", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 1 + } + ], + "params": [ + { + "name": "modules_bridge_id", + "value": "[]" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + } + ] + }, + { + "name": "Outdoor Parking Camera", + "external_id": "netatmo:70:ee:00:xx:xx:xx", + "selector": "netatmo:70:ee:00:xx:xx:xx", + "model": "NOC", + "service_id": "serviceId", + "should_poll": false, + "features": [], + "params": [ + { "name": "home_id", "value": "5e1xxxxxxxxxxxxxxxxx" }, + { "name": "room_id", "value": "8765432109" }, + { "name": "room_name", "value": "Extérieur" } + ], + "not_handled": true + } +] diff --git a/server/test/services/netatmo/netatmo.discoverDevices.mock.test.json b/server/test/services/netatmo/netatmo.discoverDevices.mock.test.json new file mode 100644 index 0000000000..512ef26c05 --- /dev/null +++ b/server/test/services/netatmo/netatmo.discoverDevices.mock.test.json @@ -0,0 +1,106 @@ +[ + { + "name": "Relais Test", + "external_id": "netatmo:70:ee:50:xx:xx:xx", + "selector": "netatmo:70:ee:50:xx:xx:xx", + "model": "NAPlug", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Link RF quality - Relais Test", + "external_id": "netatmo:70:ee:50:xx:xx:xx:rf_strength", + "selector": "netatmo:70:ee:50:xx:xx:xx:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link Wifi quality - Relais Test", + "external_id": "netatmo:70:ee:50:xx:xx:xx:wifi_strength", + "selector": "netatmo:70:ee:50:xx:xx:xx:wifi_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Relais Test connected boiler", + "external_id": "netatmo:70:ee:50:xx:xx:xx:plug_connected_boiler", + "selector": "netatmo:70:ee:50:xx:xx:xx:plug_connected_boiler", + "category": "switch", + "type": "binary", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 1 + } + ], + "params": [ + { + "name": "modules_bridge_id", + "value": "[\"01:00:00:xx:xx:xx\",\"02:00:00:xx:xx:xx\"]" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "1234567890" + }, + { + "name": "room_name", + "value": "Garage Test" + } + ], + "deviceNetatmo": { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "firmware_revision": 123, + "rf_strength": 70, + "wifi_strength": 45, + "name": "Relais Test", + "setup_date": 1580000000, + "room_id": "1234567890", + "modules_bridged": ["01:00:00:xx:xx:xx", "02:00:00:xx:xx:xx"], + "_id": "70:ee:50:xx:xx:xx", + "last_setup": 1580000000, + "firmware": 123, + "last_status_store": 1600000000, + "plug_connected_boiler": false, + "wifi_status": 50, + "last_bilan": { "y": 2022, "m": 6 }, + "station_name": "Relais Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "continent": "Europe", + "country": "FR", + "country_name": "France", + "location": [1.234, 2.345], + "street": "Rue de Test", + "timezone": "Europe/Paris", + "trust_location": true + }, + "udp_conn": true, + "last_plug_seen": 1600000000, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "1234567890", + "name": "Garage Test", + "type": "garage", + "module_ids": ["01:00:00:xx:xx:xx"] + }, + "plug": null + } + } +] diff --git a/server/test/services/netatmo/netatmo.getThermostat.mock.test.json b/server/test/services/netatmo/netatmo.getThermostat.mock.test.json new file mode 100644 index 0000000000..17a830f108 --- /dev/null +++ b/server/test/services/netatmo/netatmo.getThermostat.mock.test.json @@ -0,0 +1,63 @@ +{ + "devices": [ + { + "_id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "last_setup": 1580000589, + "firmware": 200, + "last_status_store": 1602798842, + "plug_connected_boiler": true, + "wifi_status": 45, + "modules": [ + { + "_id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "firmware": 60, + "last_message": 1602798840, + "rf_status": 60, + "battery_vp": 3000, + "therm_orientation": 1, + "therm_relay_cmd": 100, + "anticipating": false, + "module_name": "Thermostat Test", + "battery_percent": 50, + "event_history": {}, + "setpoint_history": [], + "last_therm_seen": 1602798840, + "setpoint": { + "setpoint_mode": "manual", + "setpoint_temp": 14, + "setpoint_endtime": 1703096987 + }, + "therm_program_list": [], + "measured": { + "time": 1602798668, + "temperature": 19, + "setpoint_temp": 19 + } + } + ], + "station_name": "Relais Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "udp_conn": true, + "last_plug_seen": 1602798842 + } + ], + "user": { + "mail": "tony.stark@gmail.com", + "administrative": { + "lang": "fr", + "reg_locale": "fr-FR", + "country": "FR", + "unit": 0, + "windunit": 0, + "pressureunit": 0, + "feel_like_algo": 0 + } + } +} diff --git a/server/test/services/netatmo/netatmo.homesdata.mock.test.json b/server/test/services/netatmo/netatmo.homesdata.mock.test.json new file mode 100644 index 0000000000..df06d1cc10 --- /dev/null +++ b/server/test/services/netatmo/netatmo.homesdata.mock.test.json @@ -0,0 +1,127 @@ +{ + "homes": [ + { + "id": "5e1xxxxxxxxxxxxxxxxx", + "name": "Maison Stark", + "altitude": 122, + "coordinates": [0.3289786995650213, 49.60009176402246], + "country": "FR", + "timezone": "Europe/Paris", + "rooms": [ + { + "id": "1234567890", + "name": "Garage Test", + "type": "garage", + "module_ids": ["01:00:00:xx:xx:xx"] + }, + { + "id": "0987654321", + "name": "Maison Test", + "type": "livingroom", + "module_ids": ["04:00:00:xx:xx:xx"] + }, + { + "id": "8765432109", + "name": "Extérieur", + "type": "outdoor", + "module_ids": ["70:ee:00:xx:xx:xx", "02:00:00:xx:xx:xx", "05:00:00:xx:xx:xx", "06:00:00:xx:xx:xx"] + } + ], + "modules": [ + { + "id": "70:ee:00:xx:xx:xx", + "type": "NOC", + "name": "Outdoor Parking Camera", + "setup_date": 1608493953, + "room_id": "8765432109" + }, + { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "name": "Relais Test", + "setup_date": 1580000000, + "room_id": "1234567890", + "modules_bridged": ["04:00:00:xx:xx:xx", "02:00:00:xx:xx:xx"] + }, + { + "id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "name": "Thermostat Test", + "setup_date": 1580500000, + "room_id": "0987654321", + "bridge": "70:ee:50:xx:xx:xx" + }, + { + "id": "70:ee:50:yy:yy:yy", + "type": "NAPlug", + "name": "Relais Salon Test", + "setup_date": 1578496638 + } + ], + "temperature_control_mode": "heating", + "therm_mode": "schedule", + "therm_setpoint_default_duration": 180, + "persons": [ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "pseudo": "Tony", + "url": "https://netatmocameraimage.blob.core.windows.net/production/xxx?sv=2010-01-01&sr=b&se=2023-12-31T00:01:00Z&sp=r&spr=https&sig=xxxxxx" + } + ], + "schedules": [ + { + "timetable": [ + { + "zone_id": 1, + "m_offset": 0 + }, + { + "zone_id": 6, + "m_offset": 345 + } + ], + "zones": [ + { + "name": "Confort", + "id": 0, + "type": 0, + "rooms_temp": [ + { + "room_id": "1234567890", + "temp": 17 + }, + { + "room_id": "0987654321", + "temp": 20 + } + ], + "rooms": [ + { + "id": "1456430165", + "therm_setpoint_temperature": 17 + }, + { + "id": "3416175565", + "therm_setpoint_temperature": 17 + } + ] + } + ], + "name": "Standard", + "default": false, + "away_temp": 17, + "hg_temp": 8, + "id": "5e147b4da11ec5d9f86b25a3", + "type": "therm" + } + ] + }, + { + "id": "7e2xxxxxxxxxxxxxxxxx", + "name": "Secondary House Stark", + "altitude": 1220, + "country": "US", + "modules": [] + } + ] +} diff --git a/server/test/services/netatmo/netatmo.homestatus.mock.test.json b/server/test/services/netatmo/netatmo.homestatus.mock.test.json new file mode 100644 index 0000000000..fa32dba3b7 --- /dev/null +++ b/server/test/services/netatmo/netatmo.homestatus.mock.test.json @@ -0,0 +1,90 @@ +{ + "home": { + "id": "5e1xxxxxxxxxxxxxxxxx", + "rooms": [ + { + "id": "1234567890", + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 12, + "therm_setpoint_temperature": 10, + "therm_setpoint_start_time": 1702657812, + "therm_setpoint_mode": "schedule" + }, + { + "id": "0987654321", + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 14.4, + "therm_setpoint_temperature": 14.5, + "therm_setpoint_start_time": 1703023788, + "therm_setpoint_end_time": 1703034588, + "therm_setpoint_mode": "manual" + }, + { + "id": "8765432109", + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 12, + "therm_setpoint_temperature": 10, + "therm_setpoint_start_time": 1702657812, + "therm_setpoint_mode": "schedule" + } + ], + "modules": [ + { + "id": "70:ee:00:xx:xx:xx", + "type": "NOC", + "firmware_revision": 3018000, + "wifi_state": "high", + "wifi_strength": 55, + "sd_status": 4, + "alim_status": 2, + "siren_status": "no_sound", + "vpn_url": "https://prodvpn-eu-14.netatmo.net/restricted/xx.xxx.xxx.xxx/xxxxxxxxxxxxxxxxxxxxxxxxxxxxx/yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy,,", + "is_local": true, + "floodlight": "off", + "monitoring": "on" + }, + { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "firmware_revision": 236, + "rf_strength": 108, + "wifi_strength": 35 + }, + { + "id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "battery_state": "high", + "battery_level": 4003, + "firmware_revision": 76, + "rf_strength": 68, + "reachable": true, + "boiler_valve_comfort_boost": false, + "bridge": "70:ee:50:xx:xx:xx", + "boiler_status": false + }, + { + "id": "70:ee:50:yy:yy:yy", + "type": "NAPlug", + "firmware_revision": 236, + "rf_strength": 106, + "wifi_strength": 66 + } + ], + "persons": [ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "last_seen": 1702874136, + "out_of_sight": true + } + ] + } +} diff --git a/server/test/services/netatmo/netatmo.loadDevices.mock.test.json b/server/test/services/netatmo/netatmo.loadDevices.mock.test.json new file mode 100644 index 0000000000..35d3a0cb8c --- /dev/null +++ b/server/test/services/netatmo/netatmo.loadDevices.mock.test.json @@ -0,0 +1,174 @@ +[ + { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "firmware_revision": 123, + "rf_strength": 70, + "wifi_strength": 45, + "name": "Relais Test", + "setup_date": 1580000000, + "room_id": "1234567890", + "modules_bridged": ["01:00:00:xx:xx:xx", "02:00:00:xx:xx:xx"], + "_id": "70:ee:50:xx:xx:xx", + "last_setup": 1580000000, + "firmware": 123, + "last_status_store": 1600000000, + "plug_connected_boiler": false, + "wifi_status": 50, + "last_bilan": { "y": 2022, "m": 6 }, + "station_name": "Relais Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "continent": "Europe", + "country": "FR", + "country_name": "France", + "location": [1.234, 2.345], + "street": "Rue de Test", + "timezone": "Europe/Paris", + "trust_location": true + }, + "udp_conn": true, + "last_plug_seen": 1600000000, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "1234567890", + "name": "Garage Test", + "type": "garage", + "module_ids": ["01:00:00:xx:xx:xx"] + }, + "plug": null + }, + { + "id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "battery_state": "medium", + "battery_level": 3020, + "firmware_revision": 65, + "rf_strength": 60, + "reachable": true, + "boiler_valve_comfort_boost": false, + "bridge": "70:ee:50:xx:xx:xx", + "boiler_status": true, + "name": "Thermostat Test", + "setup_date": 1580500000, + "room_id": "0987654321", + "_id": "04:00:00:xx:xx:xx", + "firmware": 65, + "last_message": 1600500000, + "rf_status": 60, + "battery_vp": 3020, + "therm_orientation": 2, + "therm_relay_cmd": 80, + "anticipating": false, + "module_name": "Thermostat Test", + "battery_percent": 60, + "event_history": { + "boiler_not_responding_events": ["event1", "event2"], + "boiler_responding_events": ["event3", "event4"] + }, + "setpoint_history": [ + { "time": 1600500000, "setpoint_temp": 19 }, + { "time": 1600600000, "setpoint_temp": 20 } + ], + "last_therm_seen": 1600500000, + "setpoint": { + "setpoint_mode": "manual", + "setpoint_temp": 14, + "setpoint_endtime": 1703096987 + }, + "therm_program_list": [ + { "monday": [{ "start": "06:00", "end": "09:00", "temp": 19 }] }, + { "tuesday": [{ "start": "06:00", "end": "09:00", "temp": 19.5 }] } + ], + "measured": { + "time": 1600498668, + "temperature": 19.6, + "setpoint_temp": 19 + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "0987654321", + "name": "Maison Test", + "type": "livingroom", + "module_ids": ["04:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 50, + "open_window": false, + "therm_measured_temperature": 19.4, + "therm_setpoint_temperature": 19.5, + "therm_setpoint_start_time": 1600500000, + "therm_setpoint_end_time": 1600800000, + "therm_setpoint_mode": "manual" + }, + "plug": { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "name": "Relais Test", + "setup_date": 1580000000, + "room_id": "1234567890", + "modules_bridged": ["01:00:00:xx:xx:xx"], + "firmware_revision": 123, + "rf_strength": 70, + "wifi_strength": 45, + "_id": "70:ee:50:xx:xx:xx", + "last_setup": 1580000000, + "firmware": 123, + "last_status_store": 1600000000, + "plug_connected_boiler": false, + "wifi_status": 50, + "last_bilan": { "y": 2022, "m": 6 }, + "station_name": "Relais Test", + "place": { + "altitude": 100, + "city": "Ville Test", + "continent": "Europe", + "country": "FR", + "country_name": "France", + "location": [1.234, 2.345], + "street": "Rue de Test", + "timezone": "Europe/Paris", + "trust_location": true + }, + "udp_conn": true, + "last_plug_seen": 1600000000 + } + }, + { + "id": "70:ee:50:yy:yy:yy", + "type": "NAPlug", + "firmware_revision": 124, + "rf_strength": 65, + "wifi_strength": 55, + "name": "Relais Salon Test", + "setup_date": 1581000000, + "_id": "70:ee:50:yy:yy:yy", + "firmware": 124, + "last_status_store": 1601000000, + "home": "5e1xxxxxxxxxxxxxxxxx", + "plug": null + }, + { + "id": "70:ee:00:xx:xx:xx", + "type": "NOC", + "firmware_revision": 3018000, + "wifi_state": "high", + "wifi_strength": 59, + "sd_status": 4, + "alim_status": 2, + "siren_status": "no_sound", + "monitoring": "on", + "name": "Outdoor Parking Camera", + "setup_date": 1608493953, + "room_id": "8765432109", + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Extérieur", + "type": "outdoor", + "module_ids": ["70:ee:00:xx:xx:xx", "02:00:00:xx:xx:xx", "05:00:00:xx:xx:xx", "06:00:00:xx:xx:xx"] + }, + "not_handled": true + } +] diff --git a/server/test/services/netatmo/netatmo.loadDevicesDetails.mock.test.json b/server/test/services/netatmo/netatmo.loadDevicesDetails.mock.test.json new file mode 100644 index 0000000000..84b0f72df5 --- /dev/null +++ b/server/test/services/netatmo/netatmo.loadDevicesDetails.mock.test.json @@ -0,0 +1,190 @@ +[ + { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "firmware_revision": 123, + "rf_strength": 70, + "wifi_strength": 45, + "name": "Relais Test", + "setup_date": 1580000000, + "room_id": "1234567890", + "modules_bridged": ["01:00:00:xx:xx:xx", "02:00:00:xx:xx:xx"], + "_id": "70:ee:50:xx:xx:xx", + "last_setup": 1580000000, + "firmware": 123, + "last_status_store": 1600000000, + "plug_connected_boiler": false, + "wifi_status": 50, + "last_bilan": { "y": 2022, "m": 6 }, + "station_name": "Relais Test", + "place": { + "altitude": 100, + "city": "Ville Test", + "continent": "Europe", + "country": "FR", + "country_name": "France", + "location": [1.234, 2.345], + "street": "Rue de Test", + "timezone": "Europe/Paris", + "trust_location": true + }, + "udp_conn": true, + "last_plug_seen": 1600000000, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "1234567890", + "name": "Garage Test", + "type": "garage", + "module_ids": ["01:00:00:xx:xx:xx"] + }, + "plug": null + }, + { + "id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "battery_state": "medium", + "battery_level": 3020, + "firmware_revision": 65, + "rf_strength": 60, + "reachable": true, + "boiler_valve_comfort_boost": false, + "bridge": "70:ee:50:xx:xx:xx", + "boiler_status": true, + "name": "Thermostat Test", + "setup_date": 1580500000, + "room_id": "0987654321", + "_id": "04:00:00:xx:xx:xx", + "firmware": 65, + "last_message": 1600500000, + "rf_status": 60, + "battery_vp": 3020, + "therm_orientation": 2, + "therm_relay_cmd": 80, + "anticipating": false, + "module_name": "Thermostat Test", + "battery_percent": 60, + "event_history": { + "boiler_not_responding_events": ["event1", "event2"], + "boiler_responding_events": ["event3", "event4"] + }, + "setpoint_history": [ + { "time": 1600500000, "setpoint_temp": 19 }, + { "time": 1600600000, "setpoint_temp": 20 } + ], + "last_therm_seen": 1600500000, + "setpoint": { + "setpoint_mode": "manual", + "setpoint_temp": 14, + "setpoint_endtime": 1703096987 + }, + "therm_program_list": [ + { "monday": [{ "start": "06:00", "end": "09:00", "temp": 19 }] }, + { "tuesday": [{ "start": "06:00", "end": "09:00", "temp": 19.5 }] } + ], + "measured": { + "time": 1600498668, + "temperature": 19.6, + "setpoint_temp": 19 + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "0987654321", + "name": "Maison Test", + "type": "livingroom", + "module_ids": ["04:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 50, + "open_window": false, + "therm_measured_temperature": 19.4, + "therm_setpoint_temperature": 19.5, + "therm_setpoint_start_time": 1600500000, + "therm_setpoint_end_time": 1600800000, + "therm_setpoint_mode": "manual" + }, + "plug": { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "name": "Relais Test", + "setup_date": 1580000000, + "room_id": "1234567890", + "modules_bridged": ["01:00:00:xx:xx:xx"], + "firmware_revision": 123, + "rf_strength": 70, + "wifi_strength": 45, + "_id": "70:ee:50:xx:xx:xx", + "last_setup": 1580000000, + "firmware": 123, + "last_status_store": 1600000000, + "plug_connected_boiler": false, + "wifi_status": 50, + "last_bilan": { "y": 2022, "m": 6 }, + "station_name": "Relais Test", + "place": { + "altitude": 100, + "city": "Ville Test", + "continent": "Europe", + "country": "FR", + "country_name": "France", + "location": [1.234, 2.345], + "street": "Rue de Test", + "timezone": "Europe/Paris", + "trust_location": true + }, + "udp_conn": true, + "last_plug_seen": 1600000000 + } + }, + { + "id": "70:ee:50:yy:yy:yy", + "type": "NAPlug", + "firmware_revision": 124, + "rf_strength": 65, + "wifi_strength": 55, + "name": "Relais Salon Test", + "setup_date": 1581000000, + "room_id": "9876543210", + "modules_bridged": ["03:00:00:xx:xx:xx", "04:00:00:xx:xx:xx", "05:00:00:xx:xx:xx"], + "_id": "70:ee:50:yy:yy:yy", + "firmware": 124, + "last_status_store": 1601000000, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "9876543210", + "name": "Salon Test", + "type": "livingroom", + "module_ids": ["03:00:00:xx:xx:xx", "04:00:00:xx:xx:xx", "05:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 40, + "open_window": false, + "therm_measured_temperature": 20.5, + "therm_setpoint_temperature": 20, + "therm_setpoint_start_time": 1601000000, + "therm_setpoint_mode": "schedule" + }, + "plug": null + }, + { + "id": "70:ee:00:xx:xx:xx", + "type": "NOC", + "firmware_revision": 3018000, + "wifi_state": "high", + "wifi_strength": 59, + "sd_status": 4, + "alim_status": 2, + "siren_status": "no_sound", + "monitoring": "on", + "name": "Outdoor Parking Camera", + "setup_date": 1608493953, + "room_id": "8765432109", + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Extérieur", + "type": "outdoor", + "module_ids": ["70:ee:00:xx:xx:xx", "02:00:00:xx:xx:xx", "05:00:00:xx:xx:xx", "06:00:00:xx:xx:xx"] + }, + "not_handled": true + } +] diff --git a/server/test/services/netatmo/netatmo.loadThermostatDetails.mock.test.json b/server/test/services/netatmo/netatmo.loadThermostatDetails.mock.test.json new file mode 100644 index 0000000000..00910234d9 --- /dev/null +++ b/server/test/services/netatmo/netatmo.loadThermostatDetails.mock.test.json @@ -0,0 +1,80 @@ +{ + "thermostats": [ + { + "_id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "last_setup": 1580000589, + "firmware": 200, + "last_status_store": 1602798842, + "plug_connected_boiler": true, + "wifi_status": 45, + "modules": [ + { + "_id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "firmware": 60, + "last_message": 1602798840, + "rf_status": 60, + "battery_vp": 3000, + "therm_orientation": 1, + "therm_relay_cmd": 100, + "anticipating": false, + "module_name": "Thermostat Test", + "battery_percent": 50, + "event_history": {}, + "setpoint_history": [], + "last_therm_seen": 1602798840, + "setpoint": { + "setpoint_mode": "manual", + "setpoint_temp": 14, + "setpoint_endtime": 1703096987 + }, + "therm_program_list": [], + "measured": { + "time": 1602798668, + "temperature": 19, + "setpoint_temp": 19 + } + } + ], + "station_name": "Relais Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "udp_conn": true, + "last_plug_seen": 1602798842 + } + ], + "modules": [ + { + "_id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "firmware": 60, + "last_message": 1602798840, + "rf_status": 60, + "battery_vp": 3000, + "therm_orientation": 1, + "therm_relay_cmd": 100, + "anticipating": false, + "module_name": "Thermostat Test", + "battery_percent": 50, + "event_history": {}, + "setpoint_history": [], + "last_therm_seen": 1602798840, + "setpoint": { + "setpoint_mode": "manual", + "setpoint_temp": 14, + "setpoint_endtime": 1703096987 + }, + "therm_program_list": [], + "measured": { + "time": 1602798668, + "temperature": 19, + "setpoint_temp": 19 + } + } + ] +} diff --git a/server/test/services/netatmo/netatmo.mock.test.js b/server/test/services/netatmo/netatmo.mock.test.js new file mode 100644 index 0000000000..60d1f43f50 --- /dev/null +++ b/server/test/services/netatmo/netatmo.mock.test.js @@ -0,0 +1,64 @@ +const sinon = require('sinon'); + +const devicesMock = require('./netatmo.loadDevices.mock.test.json'); +const deviceDetailsMock = require('./netatmo.loadDevicesDetails.mock.test.json'); +const thermostatsDetailsMock = require('./netatmo.loadThermostatDetails.mock.test.json'); +const discoverDevicesMock = require('./netatmo.discoverDevices.mock.test.json'); +const { STATUS, SCOPES } = require('../../../services/netatmo/lib/utils/netatmo.constants'); + +const NetatmoHandlerMock = { + serviceId: 'serviceId', + configuration: { + clientId: null, + clientSecret: null, + scopes: { + scopeEnergy: `${SCOPES.ENERGY.read} ${SCOPES.ENERGY.write}`, + }, + }, + configured: false, + connected: false, + redirectUri: null, + accessToken: null, + refreshToken: null, + expireInToken: null, + stateGetAccessToken: null, + status: STATUS.NOT_INITIALIZED, + pollRefreshToken: undefined, + pollRefreshValues: undefined, + init: sinon.stub().resolves(), + connect: sinon.stub().resolves(), + disconnect: sinon.stub().resolves(), + retrieveTokens: sinon.stub().resolves({ + accessToken: 'mock_access_token', + refreshToken: 'mock_refresh_token', + expireIn: 10800, + }), + setTokens: sinon.stub().resolves(), + getStatus: sinon.stub().returns(STATUS.NOT_INITIALIZED), + saveStatus: sinon.stub().resolves(), + getAccessToken: sinon.stub().resolves('mock_access_token'), + getRefreshToken: sinon.stub().resolves('mock_refresh_token'), + refreshingTokens: sinon.stub().resolves({ + accessToken: 'mock_access_token', + refreshToken: 'mock_refresh_token', + expireIn: 10800, + }), + getConfiguration: sinon.stub().resolves({ + clientId: 'mock_client_id', + clientSecret: 'mock_client_secret', + redirectUri: 'mock_redirect_uri', + }), + saveConfiguration: sinon.stub().resolves(), + discoverDevices: sinon.stub().resolves(discoverDevicesMock), + loadDevices: sinon.stub().resolves(devicesMock), + loadDeviceDetails: sinon.stub().resolves(deviceDetailsMock), + loadThermostatDetails: sinon.stub().resolves(thermostatsDetailsMock), + pollRefreshingValuess: sinon.stub().resolves(), + pollRefreshingToken: sinon.stub().resolves(), + setValue: sinon.stub().resolves(), + updateValues: sinon.stub().resolves(), +}; + +module.exports = { + NetatmoHandlerMock, +}; diff --git a/server/utils/constants.js b/server/utils/constants.js index f61b9cea24..38fbe4bcef 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -964,6 +964,14 @@ const WEBSOCKET_MESSAGE_TYPES = { STATUS: 'tuya.status', DISCOVER: 'tuya.discover', }, + NETATMO: { + STATUS: 'netatmo.status', + ERROR: { + CONNECTED: 'netatmo.error-connected', + CONNECTING: 'netatmo.error-connecting', + PROCESSING_TOKEN: 'netatmo.error-processing-token', + }, + }, MELCLOUD: { STATUS: 'melcloud.status', DISCOVER: 'melcloud.discover',